Skip to content

Commit bff6464

Browse files
committed
migrated letterboxd-grid files
1 parent f159ca0 commit bff6464

17 files changed

+424
-2
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,6 @@ cython_debug/
157157
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158158
# and can be added to the global gitignore or merged into this file. For a more nuclear
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160-
#.idea/
160+
#.idea/
161+
162+
tmdb_auth.py

config.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"resize_factor": 0.2,
3+
"image_gap": 10,
4+
"info_box_width": 500,
5+
"username_box_height": 50,
6+
"movie_info_font_size": 12,
7+
"username_font_size": 20,
8+
"font_color": [255, 255, 255]
9+
}

fetch_data.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
eventually API calls will be in place here to extract data
3+
"""
4+
5+
from bs4 import BeautifulSoup
6+
import requests
7+
from datetime import datetime
8+
import re
9+
from tmdb_fetch import get_director
10+
import aiohttp
11+
import asyncio
12+
import aiofiles
13+
from moviecell import MovieCell
14+
15+
async def download(name_url: tuple[str], session):
16+
url = name_url[1]
17+
filename = name_url[0]
18+
async with session.get(url) as response:
19+
async with aiofiles.open(filename, "wb") as f:
20+
await f.write(await response.read())
21+
22+
async def download_all(name_urls: list[tuple]):
23+
async with aiohttp.ClientSession() as session:
24+
await asyncio.gather(
25+
*[download(name_url, session=session) for name_url in name_urls]
26+
)
27+
28+
def rss_feed_exists(page_content: bytes) -> bool:
29+
30+
return "<title>Letterboxd - Not Found</title>" in page_content.decode("utf-8")
31+
32+
def scrape(user: str, month: int) -> list:
33+
# maybe we split this up into two funcs
34+
35+
url = f'https://letterboxd.com/{user}/rss/'
36+
headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36'}
37+
r = requests.get(url, headers=headers)
38+
39+
if rss_feed_exists(r.content):
40+
raise Exception("ERROR: Username does not exist")
41+
42+
43+
44+
soup = BeautifulSoup(r.content, 'xml')
45+
46+
items = soup.find_all('item')
47+
48+
def is_movie(item) -> bool:
49+
return str(item.find('link')).find(f'https://letterboxd.com/{user}/list/') == -1
50+
51+
def watched_this_month(item) -> bool:
52+
return get_watched_date(item).month == month
53+
54+
def get_watched_date(item) -> datetime:
55+
date_split = re.split(pattern='<|>', string=str(item.find("letterboxd:watchedDate")))[2].split('-')
56+
date = datetime(year=int(date_split[0]), month=int(date_split[1]), day=int(date_split[2]))
57+
return date
58+
59+
def get_movie_title(item) -> str:
60+
return re.split(pattern='<|>', string=str(item.find("letterboxd:filmTitle")))[2]
61+
62+
def get_movie_rating(item) -> int:
63+
rating_tag = item.find("letterboxd:memberRating")
64+
if not rating_tag: return -1
65+
return float(re.split(pattern='<|>', string=str(rating_tag))[2])
66+
67+
def get_poster_url(item) -> str:
68+
# attrs are broken inside description tag so we have to do this a little more manually
69+
url_slice = [m.start() for m in re.finditer('"', str(item.find('description')))]
70+
return str(item.find('description'))[url_slice[0]+1:url_slice[1]]
71+
72+
def get_tmdb_id(item) -> int:
73+
return int(re.split(pattern='<|>', string=str(item.find("tmdb:movieId")))[2])
74+
75+
def title_to_image_path(path: str):
76+
return 'images/' + path.replace(' ', '-') + '.png'
77+
78+
# sorting movies by date
79+
items = sorted(filter(is_movie, items), key=lambda x: get_watched_date(x), reverse=True)
80+
# getting movies watched this month
81+
items = list(filter(watched_this_month, items))
82+
83+
# reverse these? ^^
84+
movie_titles = list(map(get_movie_title, items))
85+
movie_ratings = list(map(get_movie_rating, items))
86+
movie_directors = list(map(get_director, map(get_tmdb_id, items)))
87+
movie_poster_paths = list(map(title_to_image_path, movie_titles))
88+
89+
# download posters
90+
asyncio.run(download_all(zip(movie_poster_paths, map(get_poster_url, items))))
91+
92+
# bundling up movies as dataclass now
93+
94+
return [MovieCell(*movie_data) for movie_data in zip(movie_titles, movie_directors, movie_ratings, movie_poster_paths)]

file_management.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import os
2+
from glob import glob
3+
import uuid
4+
def file_cleanup():
5+
files = glob('images/*.png')
6+
for f in files:
7+
os.remove(f)
8+
9+
def file_saver(username: str, image: "Image") -> str:
10+
file_name = f"{username}_{uuid.uuid4()}.png"
11+
image.save(f"images/{file_name}")
12+
return file_name

font/JuliaMono-Bold.ttf

3 MB
Binary file not shown.

grid_shape.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
Functions for getting grid size for image_builder
3+
"""
4+
5+
def get_factors(n: int) -> tuple:
6+
return [(i, n // i) for i in range(1, int(n**0.5)+1) if n % i == 0]
7+
8+
def reorder_pair(pair: tuple) -> tuple:
9+
return (max(pair), min(pair))
10+
11+
def factor_to_ratio(pair: tuple) -> float:
12+
return pair[0]/pair[1]
13+
14+
def valid_ratio_exists(ratios: list, max_ratio: float, min_ratio: float) -> bool:
15+
for ratio in ratios:
16+
if ratio >= min_ratio and ratio <= max_ratio:
17+
return True
18+
return False
19+
20+
def get_grid_size(n: int) -> tuple:
21+
MIN_RATIO = 1
22+
MAX_RATIO = 2.5
23+
24+
while True:
25+
factors = list(map(reorder_pair, get_factors(n)))
26+
print(factors)
27+
factor_ratios = list(map(factor_to_ratio, factors))
28+
29+
if not valid_ratio_exists(factor_ratios, MAX_RATIO, MIN_RATIO):
30+
n+=1
31+
continue
32+
dist_list = list(map(lambda x: abs(x - (MIN_RATIO + MAX_RATIO)/2), factor_ratios))
33+
return factors[dist_list.index(min(dist_list))]

image_builder.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""
2+
Functions for building movie grid image.
3+
To build call build(list[MovieCell], username: str)
4+
"""
5+
6+
from PIL import Image, ImageDraw, ImageFont
7+
from grid_shape import get_grid_size
8+
import json
9+
from functools import partial
10+
import datetime
11+
12+
def trans_paste(fg_img, bg_img, alpha=1.0, box=(0, 0)):
13+
fg_img_trans = Image.new("RGBA", fg_img.size)
14+
fg_img_trans = Image.blend(fg_img_trans,fg_img, alpha)
15+
bg_img.paste(fg_img_trans,box,fg_img_trans)
16+
return bg_img
17+
18+
def resize_image(im: Image, w_factor: float, h_factor: float) -> Image:
19+
new_size = (int(im.size[0] * w_factor), int(im.size[1] * h_factor))
20+
return im.resize(size=new_size)
21+
22+
def build_thumbnail(cell: "MovieCell", resize_factor: float) -> Image:
23+
return resize_image(Image.open(cell.im_path), resize_factor, resize_factor)
24+
25+
def build_background(thumbnail_width: int, thumbnail_height: int,
26+
grid_width: int, grid_height: int, text_width: int,
27+
username_box_height: int, image_gap: int) -> Image:
28+
return Image.new(
29+
mode='RGBA',
30+
size=(thumbnail_width * grid_width + image_gap * (grid_width + 1) + text_width,
31+
thumbnail_height * grid_height + image_gap * (grid_height + 1) + username_box_height),
32+
color=(50, 50, 50))
33+
34+
def get_max_text_size(text_drawer: ImageDraw, font: ImageFont, text_list: list) -> int:
35+
WIDTH_LIMIT = 500
36+
max_width = -1
37+
for text in text_list:
38+
max_width = max(text_drawer.textsize(text, font))
39+
return min(max_width, WIDTH_LIMIT)
40+
41+
def build_movie_text(movie_cell: "MovieCell") -> str:
42+
mv_text = f'{movie_cell.title} - {movie_cell.director}'
43+
if len(mv_text) > 68:
44+
director = movie_cell.director.split(' ')
45+
director[-1] = director[-1][0]
46+
mv_text = f'{movie_cell.title} - {' '.join(map(str, director)) }'
47+
return mv_text
48+
49+
def get_text_dimensions(text_string: str, font: ImageFont.truetype):
50+
# https://stackoverflow.com/a/46220683/9263761
51+
_, descent = font.getmetrics()
52+
53+
text_width = font.getmask(text_string).getbbox()[2]
54+
text_height = font.getmask(text_string).getbbox()[3] + descent
55+
56+
return (text_width, text_height)
57+
58+
def load_config(path: str) -> list:
59+
config: dict
60+
with open(path, 'r') as f:
61+
config = json.load(f)
62+
return (
63+
config['resize_factor'],
64+
config['image_gap'],
65+
config['info_box_width'],
66+
config['username_box_height'],
67+
config['movie_info_font_size'],
68+
config['username_font_size'],
69+
config['font_color']
70+
)
71+
72+
73+
def build(movie_cells: list["MovieCell"], username: str, config_path: str) -> Image.Image:
74+
75+
# loading config file
76+
resize_factor, image_gap, info_box_width, \
77+
username_box_height, movie_info_font_size, \
78+
username_font_size, font_color = load_config(config_path)
79+
80+
# create dynamically sized grid
81+
grid_width, grid_height = get_grid_size(len(movie_cells))
82+
83+
# creating thumbnails
84+
thumbnails = list(map(partial(build_thumbnail, resize_factor=resize_factor), movie_cells))
85+
thumb_width, thumb_height = thumbnails[0].size
86+
87+
# create background
88+
bg = build_background(thumb_width, thumb_height, grid_width, grid_height,
89+
info_box_width, username_box_height, image_gap)
90+
91+
# defining fonts
92+
info_font = ImageFont.truetype('./font/JuliaMono-Bold.ttf', movie_info_font_size, encoding='utf-8')
93+
username_font = ImageFont.truetype('./font/JuliaMono-Bold.ttf', username_font_size, encoding='utf-8')
94+
text_drawer = ImageDraw.Draw(bg)
95+
96+
# writing username and date to image
97+
my_date = datetime.datetime.now()
98+
username_str = f'{username} - {my_date.strftime("%B")} {my_date.strftime("%Y")}'
99+
username_width, username_height = get_text_dimensions(username_str, username_font)
100+
username_x = bg._size[0]//2 - username_width//2
101+
username_y = username_box_height//2 - username_height//2
102+
103+
text_drawer.text((username_x, username_y), username_str, font=username_font,fill=tuple(font_color))
104+
105+
cell_index = 0
106+
107+
# paste thumbnails, text, and stars to background
108+
for i in range(grid_width):
109+
for j in range(grid_height):
110+
if cell_index >= len(movie_cells): break
111+
112+
# thumbnails
113+
im_x = i * thumb_width + image_gap * (i+1)
114+
im_y = j * thumb_height + image_gap * (j+1) + username_box_height
115+
116+
bg.paste(thumbnails[cell_index], (im_x, im_y))
117+
118+
# text
119+
txt_x = grid_width * thumb_width + image_gap * (grid_width+1)
120+
txt_y = (j % grid_width) * thumb_height + image_gap * ((j % grid_width) + 1) + (i*20) + username_box_height
121+
122+
txt_str = build_movie_text(movie_cells[cell_index])
123+
print(f'{txt_str} -> {len(txt_str)}')
124+
125+
text_drawer.text((txt_x, txt_y), txt_str, font=info_font, fill=tuple(font_color))
126+
127+
# bg.paste(star, (im_x, im_y + star.size[1] - star.size[1]))
128+
129+
# bg = trans_paste(star, bg, alpha=1.0, box=(im_x, im_y + star.size[1] - star.size[1]))
130+
cell_index += 1
131+
132+
return bg

main.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from datetime import datetime
2+
from fetch_data import scrape
3+
from time import time
4+
from file_management import file_cleanup, file_saver
5+
from image_builder import build
6+
from ratio_tester import get_moviecells
7+
from flask import Flask, redirect, url_for, request, make_response, send_file
8+
import os
9+
10+
app = Flask(__name__)
11+
IMAGES_DIRECTORY = './images'
12+
13+
# @app.route('/fetch/<filename>', methods=['GET', 'POST'])
14+
def return_mosaic(filename: str):
15+
print('return mosaic started')
16+
try:
17+
file_path = os.path.join(IMAGES_DIRECTORY, filename)
18+
print(f'OS FILE PATH: {file_path}')
19+
if os.path.isfile(file_path):
20+
return send_file(file_path, as_attachment=True)
21+
else:
22+
return make_response(f'File {filename} not found.', 404)
23+
except Exception as e:
24+
return make_response(f'Error: {str(e)}'), 500
25+
26+
27+
28+
@app.route('/getimage', methods=['POST', 'GET'])
29+
def homepage():
30+
if request.method == 'POST':
31+
username = request.form['nm']
32+
try:
33+
movie_cells = scrape(username, datetime.now().month)
34+
except Exception("ERROR: Username does not exist"):
35+
print('EXCEPTING')
36+
return make_response(f'Username {username} does not exist.', 404)
37+
38+
39+
image = build(movie_cells, username, 'config.json')
40+
file_path = file_saver(username, image)
41+
return return_mosaic(file_path)
42+
else:
43+
username = request.form['nm']
44+
image = build(scrape(username, datetime.now().month), username, 'config.json')
45+
file_path = file_saver(username, image)
46+
return return_mosaic(file_path)
47+
return redirect(url_for(f'fetch', filename=file_path))
48+
# def get_image():
49+
# username = 'scooterwhiskey'
50+
# return build(scrape(username, datetime.now().month), username, 'config.json')
51+
52+
53+
if __name__ == '__main__':
54+
# image_path = 'images/705221-furiosa-a-mad-max-saga-0-1000-0-1500-crop.jpg'
55+
t0 = time()
56+
# username = 'scooterwhiskey'
57+
# cells = scrape(username, datetime.now().month)
58+
59+
# for cell in cells:
60+
# print(vars(cell))
61+
62+
# replace cells with get_moviecells(n) for testing with dummy data
63+
# build(cells, username, 'config.json').show()
64+
65+
app.run(debug=True)
66+
# delete stored files
67+
file_cleanup()
68+
t1 = time()
69+
70+
print(f'PROGRAM FINISHED IN {t1-t0}')
71+

moviecell.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""
2+
Dataclass for storing movie data taken from fetch_data.py
3+
"""
4+
from dataclasses import dataclass
5+
6+
@dataclass
7+
class MovieCell:
8+
title: str
9+
director: str
10+
rating: int
11+
im_path: str

ratio_tester.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from moviecell import MovieCell
2+
3+
4+
def get_moviecells(n: int) -> list[MovieCell]:
5+
return [MovieCell(
6+
title='The Fifth Element',
7+
director='Jeff Green',
8+
rating=3.5,
9+
im_path='images/The-Fifth-Element.png'
10+
) for _ in range(n)]

0 commit comments

Comments
 (0)