diff --git a/api/main.py b/api/main.py index be68185..e59938a 100644 --- a/api/main.py +++ b/api/main.py @@ -1,8 +1,10 @@ +import asyncio import json import logging from io import BytesIO from typing import Literal +import aiohttp import redis.asyncio as redis from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger @@ -437,11 +439,18 @@ async def get_poster( return StreamingResponse( image_byte_io, media_type="image/jpeg", headers=const.DEFAULT_HEADERS ) + except asyncio.TimeoutError: + logging.error("Poster generation timeout.") + raise HTTPException(status_code=404, detail="Poster generation timeout.") + except aiohttp.ClientResponse as e: + logging.error(f"Failed to create poster: {e}, status: {e.status}") + if e.status != 404: + raise HTTPException(status_code=404, detail="Failed to create poster.") except Exception as e: logging.error(f"Unexpected error while creating poster: {e}") - mediafusion_data.is_poster_working = False - await mediafusion_data.save() - raise HTTPException(status_code=404, detail="Failed to create poster.") + mediafusion_data.is_poster_working = False + await mediafusion_data.save() + raise HTTPException(status_code=404, detail="Failed to create poster.") @app.get("/scraper", tags=["scraper"]) diff --git a/db/models.py b/db/models.py index f9025f6..14724a2 100644 --- a/db/models.py +++ b/db/models.py @@ -72,6 +72,7 @@ class MediaFusionMetaData(Document): year: Optional[int] = None poster: Optional[str] = None is_poster_working: Optional[bool] = True + is_add_title_to_poster: Optional[bool] = False background: Optional[str] = None streams: list[Link[TorrentStreams]] type: str diff --git a/mediafusion_scrapy/spiders/sport_video.py b/mediafusion_scrapy/spiders/sport_video.py index fdc2a5c..f3b81a7 100644 --- a/mediafusion_scrapy/spiders/sport_video.py +++ b/mediafusion_scrapy/spiders/sport_video.py @@ -100,6 +100,7 @@ class SportVideoSpider(scrapy.Spider): "website": torrent_page_link, "is_parse_ptn": False, "source": "sport-video.org.ua", + "is_add_title_to_poster": True, "catalog": category, } }, diff --git a/resources/fonts/IBMPlexSans-Bold.ttf b/resources/fonts/IBMPlexSans-Bold.ttf new file mode 100644 index 0000000..e5389d8 Binary files /dev/null and b/resources/fonts/IBMPlexSans-Bold.ttf differ diff --git a/utils/poster.py b/utils/poster.py index 6b51c8f..e7307b6 100644 --- a/utils/poster.py +++ b/utils/poster.py @@ -1,52 +1,64 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor from io import BytesIO import aiohttp -from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError +from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError, ImageStat from imdb import Cinemagoer from db.models import MediaFusionMetaData from utils import const ia = Cinemagoer() +font_cache = {} +executor = ThreadPoolExecutor(max_workers=4) -async def create_poster(mediafusion_data: MediaFusionMetaData) -> BytesIO: +async def fetch_poster_image(url: str) -> bytes: async with aiohttp.ClientSession() as session: - async with session.get( - mediafusion_data.poster, timeout=10, headers=const.UA_HEADER - ) as response: + async with session.get(url, timeout=10, headers=const.UA_HEADER) as response: response.raise_for_status() - # Check if the response content type is an image if not response.headers["Content-Type"].lower().startswith("image/"): raise ValueError( - f"Unexpected content type: {response.headers['Content-Type']} for URL: {mediafusion_data.poster}" + f"Unexpected content type: {response.headers['Content-Type']} for URL: {url}" ) - content = await response.read() + return await response.read() - # Check if the response content is not empty - if not content: - raise ValueError(f"Empty content for URL: {mediafusion_data.poster}") +# Synchronous function for CPU-bound task: image processing +def process_poster_image( + content: bytes, mediafusion_data: MediaFusionMetaData +) -> BytesIO: try: image = Image.open(BytesIO(content)).convert("RGBA") + image = image.resize((300, 450)) + imdb_rating = None # Assume you fetch this rating elsewhere if needed + + # The add_elements_to_poster function would be synchronous + image = add_elements_to_poster(image, imdb_rating) + if mediafusion_data.is_add_title_to_poster: + # The add_title_to_poster function would also be synchronous + image = add_title_to_poster(image, mediafusion_data.title) + + image = image.convert("RGB") + + byte_io = BytesIO() + image.save(byte_io, "JPEG") + byte_io.seek(0) + + return byte_io except UnidentifiedImageError: raise ValueError(f"Cannot identify image from URL: {mediafusion_data.poster}") - image = image.resize((300, 450)) - imdb_rating = None - if mediafusion_data.id.startswith("tt"): - try: - result = ia.get_movie(mediafusion_data.id[2:], info="main") - imdb_rating = result.get("rating") - except Exception: - pass - image = add_elements_to_poster(image, imdb_rating) - image = image.convert("RGB") +async def create_poster(mediafusion_data: MediaFusionMetaData) -> BytesIO: + content = await fetch_poster_image(mediafusion_data.poster) - byte_io = BytesIO() - image.save(byte_io, "JPEG") - byte_io.seek(0) + loop = asyncio.get_event_loop() + byte_io = await asyncio.wait_for( + loop.run_in_executor(executor, process_poster_image, content, mediafusion_data), + 30, + ) return byte_io @@ -59,7 +71,7 @@ def add_elements_to_poster( # Adding IMDb rating at the bottom left with a semi-transparent background if imdb_rating: - font = ImageFont.truetype("resources/fonts/IBMPlexSans-Medium.ttf", size=24) + font = load_font("resources/fonts/IBMPlexSans-Medium.ttf", 24) imdb_text = f"IMDb: {imdb_rating}/10" # Calculate text bounding box using the draw instance @@ -95,3 +107,117 @@ def add_elements_to_poster( image.paste(watermark, watermark_position, watermark) return image + + +def load_font(font_path, font_size): + if (font_path, font_size) not in font_cache: + font_cache[(font_path, font_size)] = ImageFont.truetype( + font_path, size=font_size + ) + return font_cache[(font_path, font_size)] + + +# Function to split the title into multiple lines +def split_title(title, font, draw, max_width): + lines = [] + words = title.split() + average_character_width = sum( + draw.textbbox((0, 0), char, font=font)[2] for char in set(title) + ) / len(set(title)) + while words: + line = "" + line_width = 0 + while words and line_width + average_character_width <= max_width: + word_width = draw.textbbox((0, 0), words[0], font=font)[2] + if line_width + word_width <= max_width: + line += words.pop(0) + " " + line_width += word_width + average_character_width # Add space width + else: + break + lines.append(line.strip()) + return lines + + +# Function to adjust font size and split title +def adjust_font_and_split(title, font_path, max_width, initial_font_size, draw): + font_size = initial_font_size + font = load_font(font_path, font_size) + lines = split_title(title, font, draw, max_width) + while True: + # Get the bounding box of the last line of text + bbox = draw.textbbox((0, 0), lines[-1], font=font) + text_width = bbox[2] - bbox[0] + if len(lines) > 2 or text_width >= max_width: + font_size -= 1 # Decrease font size by 1 + font = load_font(font_path, font_size) + lines = split_title(title, font, draw, max_width) + else: + break + return lines, font + + +def get_average_color(image, bbox): + # Crop the image to the bounding box + cropped_image = image.crop(bbox) + # Get the average color of the cropped image + stat = ImageStat.Stat(cropped_image) + return stat.mean + + +def text_color_based_on_background(average_color): + # Calculate the perceived brightness of the average color + brightness = sum( + [average_color[i] * v for i, v in enumerate([0.299, 0.587, 0.114])] + ) + if brightness > 128: + return "black", "white" # Dark text, light outline + else: + return "white", "black" # Light text, dark outline + + +# Function to draw text with an outline +def draw_text_with_outline(draw, position, text, font, fill_color, outline_color): + x, y = position + # Slightly thicker outlines can be more performant than multiple thin ones + outline_width = 3 + # Offset coordinates for the outline + outline_offsets = [ + (dx, dy) + for dx in range(-outline_width, outline_width + 1) + for dy in range(-outline_width, outline_width + 1) + if dx or dy + ] + for offset in outline_offsets: + draw.text((x + offset[0], y + offset[1]), text, font=font, fill=outline_color) + draw.text(position, text, font=font, fill=fill_color) + + +def add_title_to_poster(image: Image.Image, title_text: str) -> Image.Image: + draw = ImageDraw.Draw(image) + max_width = image.width - 20 # max width for the text + font_path = "resources/fonts/IBMPlexSans-Bold.ttf" + initial_font_size = 50 # Starting font size which will be adjusted dynamically + + lines, font = adjust_font_and_split( + title_text, font_path, max_width, initial_font_size, draw + ) + + # Calculate the total height of the text block using textbbox + text_block_height = sum(draw.textbbox((0, 0), line, font=font)[3] for line in lines) + + # Starting y position, centered vertically + y = (image.height - text_block_height) // 2 + sample_area = (0, y, image.width, y + text_block_height) + average_color = get_average_color(image, sample_area) + text_color, outline_color = text_color_based_on_background(average_color) + + # Draw each line of text + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + line_width = bbox[2] - bbox[0] + line_height = bbox[3] - bbox[1] + x = (image.width - line_width) // 2 # Center horizontally + draw_text_with_outline(draw, (x, y), line, font, text_color, outline_color) + y += line_height # Move y position for next line + + return image