From 9291a97df9ad9d00e0d496924c23b26b9b02a5c3 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 20 Apr 2026 23:16:53 +1200 Subject: [PATCH] Improve handling of previews --- AGENT.MD | 5 +- README.md | 14 + app.py | 1482 +++++++++++++++++------------- docker-compose.yml | 5 + emby-cover.code-workspace | 7 + templates/airing.html | 1 + templates/collections.html | 1786 ++++++++++++++++++++++++++++++++++++ templates/index.html | 1044 ++++++++------------- 8 files changed, 3056 insertions(+), 1288 deletions(-) create mode 100644 emby-cover.code-workspace create mode 100644 templates/collections.html diff --git a/AGENT.MD b/AGENT.MD index ef3ec40..bee2295 100644 --- a/AGENT.MD +++ b/AGENT.MD @@ -190,7 +190,10 @@ Remote image search: - `GET /api/artwork/providers` exposes available providers to the UI - `GET /api/artwork/search` calls the selected provider - `POST /api/artwork/import` downloads, validates, size-limits, and caches a selected remote image -- The first provider is Wikimedia Commons; add future providers by implementing `ImageSearchProvider` and registering it in `IMAGE_SEARCH_PROVIDERS` +- Current providers are Wikimedia Commons, optional TMDB, and optional Google Custom Search +- TMDB is enabled with `TMDB_BEARER_TOKEN` or `TMDB_API_KEY` +- Google Custom Search is enabled with `GOOGLE_CUSTOM_SEARCH_API_KEY` and `GOOGLE_CUSTOM_SEARCH_ENGINE_ID` +- Add future providers by implementing `ImageSearchProvider` and registering it in `IMAGE_SEARCH_PROVIDERS` ## Rendering Pipeline diff --git a/README.md b/README.md index 23d880b..5cd64fc 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,20 @@ A self-hosted web UI that generates landscape thumbnails from your Emby library 3. Give it a name (e.g. "Thumb Generator") 4. Copy the key +## Optional Artwork Providers + +The artwork editor always supports Emby images and Wikimedia Commons. You can enable additional search providers with environment variables: + +```bash +export TMDB_BEARER_TOKEN=your-tmdb-read-access-token +# or: export TMDB_API_KEY=your-tmdb-v3-api-key + +export GOOGLE_CUSTOM_SEARCH_API_KEY=your-google-api-key +export GOOGLE_CUSTOM_SEARCH_ENGINE_ID=your-google-search-engine-id +``` + +TMDB is the preferred external artwork source for posters and backdrops. Google Custom Search is optional and only uses Google's official Custom Search JSON API. + ## Notes - First generation will be slower as rembg downloads the U2-Net model (~170MB) diff --git a/app.py b/app.py index 20021a9..0c1351c 100644 --- a/app.py +++ b/app.py @@ -4,9 +4,8 @@ import os import hashlib import base64 import time -import urllib.parse +from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone -from dataclasses import dataclass from pathlib import Path import httpx @@ -16,17 +15,16 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError -app = FastAPI(title="Emby Thumbnail Generator") -app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") - EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096") EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "b9af54b630f6448289ab96422add567a") CACHE_DIR = Path("cache") CACHE_DIR.mkdir(exist_ok=True) IMPORT_CACHE_DIR = CACHE_DIR / "imports" IMPORT_CACHE_DIR.mkdir(exist_ok=True) -RENDER_VERSION = "series-banner-v10" +EMBY_IMAGE_CACHE_DIR = CACHE_DIR / "emby_images" +EMBY_IMAGE_CACHE_DIR.mkdir(exist_ok=True) +RENDER_VERSION = "series-banner-v11" +COLLECTION_RENDER_VERSION = "collection-cover-v1" THUMB_WIDTH = 800 THUMB_HEIGHT = 450 @@ -34,13 +32,10 @@ PRIMARY_WIDTH = 1000 PRIMARY_HEIGHT = 1500 PRIMARY_MIN_ZOOM = 1.0 PRIMARY_MAX_ZOOM = 2.6 -REMOTE_IMAGE_MAX_BYTES = 18 * 1024 * 1024 -EDITOR_TARGETS = { - "poster": {"width": 1000, "height": 1500, "emby_type": "Primary", "label": "Poster"}, - "thumb": {"width": 1920, "height": 1080, "emby_type": "Thumb", "label": "Thumb"}, - "backdrop": {"width": 1920, "height": 1080, "emby_type": "Backdrop", "label": "Backdrop"}, -} +UPLOAD_MAX_BYTES = 40 * 1024 * 1024 HTTP_TIMEOUT = 30.0 +EMBY_SOURCE_CACHE_TTL = 300 +EMBY_SOURCE_MISS_TTL = 90 http_client: httpx.AsyncClient | None = None AIRING_LOOKUP_CACHE_TTL = 900 NEW_SEASON_MIN_AGE_DAYS = 7 @@ -106,152 +101,89 @@ def validate_image_bytes(image_bytes: bytes, *, max_pixels: int = 24_000_000) -> return width, height, fmt -def get_editor_target(target_mode: str) -> dict: - target = EDITOR_TARGETS.get(target_mode) - if target is None: - raise HTTPException(status_code=400, detail="Invalid artwork target mode.") - return target - - -def get_import_cache_path(import_id: str) -> Path: - if not import_id or any(ch not in "0123456789abcdef" for ch in import_id.lower()): - raise HTTPException(status_code=400, detail="Invalid imported image id.") - path = IMPORT_CACHE_DIR / f"{import_id}.img" +def get_upload_cache_path(upload_id: str) -> Path: + if not upload_id or any(ch not in "0123456789abcdef" for ch in upload_id.lower()): + raise HTTPException(status_code=400, detail="Invalid upload id.") + path = IMPORT_CACHE_DIR / f"{upload_id}.img" if not path.exists(): - raise HTTPException(status_code=404, detail="Imported image was not found in cache.") + raise HTTPException(status_code=404, detail="Uploaded background not found in cache.") return path -async def resolve_editor_source_bytes(body: dict) -> bytes: - source_kind = body.get("source_kind", "emby") - item_id = body.get("item_id") - if source_kind == "import": - return get_import_cache_path(body.get("import_id", "")).read_bytes() - if source_kind != "emby": - raise HTTPException(status_code=400, detail="Invalid source image kind.") - if not item_id: - raise HTTPException(status_code=400, detail="Missing item id.") - image_type = body.get("source_type", "Primary") - if image_type not in {"Primary", "Thumb", "Backdrop"}: - raise HTTPException(status_code=400, detail="Invalid Emby source image type.") - index_raw = body.get("source_index", None) - index = int(index_raw) if index_raw is not None else None - return await emby_get_image(item_id, image_type, index=index) +def get_emby_image_cache_key(item_id: str, image_type: str, index: int | None = None) -> str: + payload = f"{item_id}:{image_type}:{index if index is not None else 'default'}" + return hashlib.md5(payload.encode("utf-8")).hexdigest() -def image_to_data_url(image_bytes: bytes, media_type: str = "image/jpeg") -> str: - return f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('ascii')}" +def get_emby_image_cache_path(item_id: str, image_type: str, index: int | None = None) -> Path: + return EMBY_IMAGE_CACHE_DIR / f"{get_emby_image_cache_key(item_id, image_type, index)}.img" -def fit_image_positioned( - img: Image.Image, - width: int, - height: int, +def get_emby_image_miss_path(item_id: str, image_type: str, index: int | None = None) -> Path: + return EMBY_IMAGE_CACHE_DIR / f"{get_emby_image_cache_key(item_id, image_type, index)}.missing" + + +def get_fresh_cached_bytes(path: Path, ttl_seconds: int) -> bytes | None: + if not path.exists(): + return None + age = time.time() - path.stat().st_mtime + if age > ttl_seconds: + return None + return path.read_bytes() + + +async def get_cached_emby_source_image( + item_id: str, + image_type: str, + index: int | None = None, *, - fit_mode: str = "cover", - zoom: float = 1.0, - pan_x: float = 0.0, - pan_y: float = 0.0, -) -> tuple[Image.Image, tuple[int, int]]: - fit_mode = fit_mode if fit_mode in {"cover", "contain"} else "cover" - pan_x = max(-1.0, min(1.0, float(pan_x))) - pan_y = max(-1.0, min(1.0, float(pan_y))) - zoom = max(0.25, min(5.0, float(zoom))) - scale_fn = max if fit_mode == "cover" else min - scale = scale_fn(width / max(1, img.width), height / max(1, img.height)) * zoom - fitted_w = max(1, int(round(img.width * scale))) - fitted_h = max(1, int(round(img.height * scale))) - fitted = img.resize((fitted_w, fitted_h), Image.LANCZOS) + optional: bool = False, +) -> bytes | None: + cache_path = get_emby_image_cache_path(item_id, image_type, index) + cached_bytes = get_fresh_cached_bytes(cache_path, EMBY_SOURCE_CACHE_TTL) + if cached_bytes is not None: + return cached_bytes - if fitted_w >= width: - x = -int(round((fitted_w - width) * ((pan_x + 1.0) / 2.0))) - else: - x = int(round((width - fitted_w) * ((pan_x + 1.0) / 2.0))) - if fitted_h >= height: - y = -int(round((fitted_h - height) * ((pan_y + 1.0) / 2.0))) - else: - y = int(round((height - fitted_h) * ((pan_y + 1.0) / 2.0))) - return fitted, (x, y) + miss_path = get_emby_image_miss_path(item_id, image_type, index) + if optional and miss_path.exists() and (time.time() - miss_path.stat().st_mtime) <= EMBY_SOURCE_MISS_TTL: + return None - -def build_editor_fill( - img: Image.Image, - width: int, - height: int, - *, - fill_mode: str, - fill_color: str | None, -) -> Image.Image: - if fill_mode == "solid": - try: - color = ImageColor.getrgb(fill_color or "#111318") - except ValueError: - color = (17, 19, 24) - return Image.new("RGB", (width, height), color) - - base = cover_crop(img, width, height) - if fill_mode == "mirror": - base = ImageOps.mirror(base) - overlay = Image.new("RGBA", (width, height), (0, 0, 0, 54)) - return Image.alpha_composite(base.convert("RGBA"), overlay).convert("RGB") - - base = base.filter(ImageFilter.GaussianBlur(radius=max(18, int(min(width, height) * 0.035)))) - overlay = Image.new("RGBA", (width, height), (0, 0, 0, 70)) - return Image.alpha_composite(base.convert("RGBA"), overlay).convert("RGB") - - -def render_artwork_editor_image( - source_bytes: bytes, - *, - target_mode: str = "poster", - fit_mode: str = "cover", - fill_mode: str = "blur", - fill_color: str | None = "#101318", - zoom: float = 1.0, - pan_x: float = 0.0, - pan_y: float = 0.0, - quality: int = 95, -) -> bytes: - target = get_editor_target(target_mode) - width = target["width"] - height = target["height"] - source = load_image_from_bytes(source_bytes, mode="RGB") - canvas = build_editor_fill(source, width, height, fill_mode=fill_mode, fill_color=fill_color) - fitted, (x, y) = fit_image_positioned( - source, - width, - height, - fit_mode=fit_mode, - zoom=zoom, - pan_x=pan_x, - pan_y=pan_y, + image_bytes = ( + await emby_get_image_optional(item_id, image_type, index=index) + if optional + else await emby_get_image(item_id, image_type, index=index) ) - canvas.paste(fitted, (x, y)) - return encode_jpeg_bytes(canvas, quality=max(80, min(98, int(quality)))) + if image_bytes is None: + if optional: + miss_path.touch() + return None + raise HTTPException(status_code=404, detail=f"Emby image {image_type} was not found.") - -def build_editor_cache_key(body: dict) -> str: - payload = { - "version": "artwork-editor-v1", - "item_id": body.get("item_id"), - "source_kind": body.get("source_kind", "emby"), - "source_type": body.get("source_type", "Primary"), - "source_index": body.get("source_index"), - "import_id": body.get("import_id"), - "target_mode": body.get("target_mode", "poster"), - "fit_mode": body.get("fit_mode", "cover"), - "fill_mode": body.get("fill_mode", "blur"), - "fill_color": body.get("fill_color", "#101318"), - "zoom": round(float(body.get("zoom", 1.0)), 4), - "pan_x": round(float(body.get("pan_x", 0.0)), 4), - "pan_y": round(float(body.get("pan_y", 0.0)), 4), - "quality": int(body.get("quality", 95)), - } - return hashlib.md5(repr(payload).encode("utf-8")).hexdigest() + cache_path.write_bytes(image_bytes) + if miss_path.exists(): + miss_path.unlink(missing_ok=True) + return image_bytes # --- Emby API helpers --- + +@asynccontextmanager +async def lifespan(app: FastAPI): + get_http_client() + try: + yield + finally: + global http_client + if http_client is not None: + await http_client.aclose() + http_client = None + + +app = FastAPI(title="Emby Thumbnail Generator", lifespan=lifespan) +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + def get_http_client() -> httpx.AsyncClient: global http_client if http_client is None: @@ -259,26 +191,51 @@ def get_http_client() -> httpx.AsyncClient: return http_client -@app.on_event("startup") -async def startup_event(): - get_http_client() - - -@app.on_event("shutdown") -async def shutdown_event(): - global http_client - if http_client is not None: - await http_client.aclose() - http_client = None - -async def emby_get(path: str, params: dict = None) -> dict: +async def emby_request(method: str, path: str, *, params: dict | None = None, **kwargs) -> httpx.Response: client = get_http_client() url = f"{EMBY_URL}{path}" - p = params or {} + p = dict(params or {}) p["api_key"] = EMBY_API_KEY - r = await client.get(url, params=p) - r.raise_for_status() - return r.json() + try: + return await client.request(method, url, params=p, **kwargs) + except httpx.ReadTimeout as exc: + raise HTTPException( + status_code=504, + detail=f"Emby timed out while requesting {path}. Try again in a moment.", + ) from exc + except httpx.ConnectTimeout as exc: + raise HTTPException( + status_code=504, + detail=f"Timed out connecting to Emby at {EMBY_URL}.", + ) from exc + except httpx.ConnectError as exc: + raise HTTPException( + status_code=502, + detail=f"Could not connect to Emby at {EMBY_URL}.", + ) from exc + except httpx.RequestError as exc: + raise HTTPException( + status_code=502, + detail=f"Emby request failed while requesting {path}: {exc}", + ) from exc + + +def ensure_emby_success(response: httpx.Response, *, context: str) -> httpx.Response: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = response.text.strip() or str(exc) + raise HTTPException( + status_code=502, + detail=f"{context} failed ({response.status_code}): {detail}", + ) from exc + return response + + +async def emby_get(path: str, params: dict = None) -> dict: + response = await emby_request("GET", path, params=params) + ensure_emby_success(response, context=f"Emby request to {path}") + return response.json() async def emby_get_image( @@ -290,8 +247,6 @@ async def emby_get_image( max_height: int | None = None, quality: int | None = None, ) -> bytes: - client = get_http_client() - url = f"{EMBY_URL}/Items/{item_id}/Images/{image_type}" params: dict[str, str | int] = {"api_key": EMBY_API_KEY} if index is not None: params["Index"] = index @@ -301,9 +256,9 @@ async def emby_get_image( params["maxHeight"] = max_height if quality is not None: params["quality"] = quality - r = await client.get(url, params=params) - r.raise_for_status() - return r.content + response = await emby_request("GET", f"/Items/{item_id}/Images/{image_type}", params=params) + ensure_emby_success(response, context=f"Emby image request for {image_type}") + return response.content async def emby_get_image_with_type( @@ -315,8 +270,6 @@ async def emby_get_image_with_type( max_height: int | None = None, quality: int | None = None, ) -> tuple[bytes, str]: - client = get_http_client() - url = f"{EMBY_URL}/Items/{item_id}/Images/{image_type}" params: dict[str, str | int] = {"api_key": EMBY_API_KEY} if index is not None: params["Index"] = index @@ -326,27 +279,25 @@ async def emby_get_image_with_type( params["maxHeight"] = max_height if quality is not None: params["quality"] = quality - r = await client.get(url, params=params) - r.raise_for_status() - content_type = (r.headers.get("content-type", "image/jpeg") or "image/jpeg").split(";")[0] - return r.content, content_type + response = await emby_request("GET", f"/Items/{item_id}/Images/{image_type}", params=params) + ensure_emby_success(response, context=f"Emby image request for {image_type}") + content_type = (response.headers.get("content-type", "image/jpeg") or "image/jpeg").split(";")[0] + return response.content, content_type async def emby_get_image_optional(item_id: str, image_type: str, index: int | None = None) -> bytes | None: """Returns image bytes or None if the image type doesn't exist for this item.""" - client = get_http_client() - url = f"{EMBY_URL}/Items/{item_id}/Images/{image_type}" params: dict[str, str | int] = {"api_key": EMBY_API_KEY} if index is not None: params["Index"] = index - r = await client.get(url, params=params) - if r.status_code in (404, 400): + response = await emby_request("GET", f"/Items/{item_id}/Images/{image_type}", params=params) + if response.status_code in (404, 400): return None - r.raise_for_status() - content_type = r.headers.get("content-type", "").lower() + ensure_emby_success(response, context=f"Optional Emby image request for {image_type}") + content_type = response.headers.get("content-type", "").lower() if content_type and not content_type.startswith("image/"): return None - return r.content + return response.content async def emby_upload_image(item_id: str, image_bytes: bytes, image_type: str = "Thumb"): @@ -355,10 +306,9 @@ async def emby_upload_image(item_id: str, image_bytes: bytes, image_type: str = upload_image.save(upload_buf, format="JPEG", quality=92, optimize=True) upload_b64 = base64.b64encode(upload_buf.getvalue()) - client = get_http_client() - url = f"{EMBY_URL}/Items/{item_id}/Images/{image_type}" - r = await client.post( - url, + response = await emby_request( + "POST", + f"/Items/{item_id}/Images/{image_type}", params={"api_key": EMBY_API_KEY}, content=upload_b64, headers={ @@ -366,84 +316,12 @@ async def emby_upload_image(item_id: str, image_bytes: bytes, image_type: str = "X-Emby-Token": EMBY_API_KEY, }, ) - try: - r.raise_for_status() - except httpx.HTTPStatusError as exc: - detail = r.text.strip() or str(exc) - raise HTTPException( - status_code=502, - detail=f"Emby upload failed ({r.status_code}): {detail}", - ) from exc - return r.status_code + ensure_emby_success(response, context=f"Emby upload for {image_type}") + return response.status_code -@dataclass -class ArtworkSearchResult: - id: str - title: str - thumbnail_url: str - image_url: str - source_url: str - provider: str - width: int | None = None - height: int | None = None -class ImageSearchProvider: - key = "base" - label = "Base" - - async def search(self, query: str, limit: int = 12) -> list[ArtworkSearchResult]: - raise NotImplementedError - - -class WikimediaImageSearchProvider(ImageSearchProvider): - key = "wikimedia" - label = "Wikimedia Commons" - - async def search(self, query: str, limit: int = 12) -> list[ArtworkSearchResult]: - client = get_http_client() - params = { - "action": "query", - "generator": "search", - "gsrnamespace": "6", - "gsrsearch": query, - "gsrlimit": str(max(1, min(24, limit))), - "prop": "imageinfo", - "iiprop": "url|size|mime", - "iiurlwidth": "420", - "format": "json", - "origin": "*", - } - r = await client.get("https://commons.wikimedia.org/w/api.php", params=params) - r.raise_for_status() - pages = (r.json().get("query") or {}).get("pages") or {} - results: list[ArtworkSearchResult] = [] - for page in pages.values(): - info = (page.get("imageinfo") or [{}])[0] - mime = info.get("mime", "") - if not mime.startswith("image/"): - continue - image_url = info.get("url") - if not image_url: - continue - title = (page.get("title") or "Artwork").replace("File:", "", 1) - results.append(ArtworkSearchResult( - id=hashlib.md5(image_url.encode("utf-8")).hexdigest(), - title=title, - thumbnail_url=info.get("thumburl") or image_url, - image_url=image_url, - source_url=info.get("descriptionurl") or image_url, - provider=self.key, - width=info.get("width"), - height=info.get("height"), - )) - return results - - -IMAGE_SEARCH_PROVIDERS: dict[str, ImageSearchProvider] = { - WikimediaImageSearchProvider.key: WikimediaImageSearchProvider(), -} def parse_emby_datetime(value: str | None) -> datetime | None: @@ -897,11 +775,149 @@ def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> list: return lines +def parse_generator_request(body: dict) -> dict: + logo_index_raw = body.get("logo_index", None) + logo_index = int(logo_index_raw) if logo_index_raw is not None else None + return { + "item_id": body["item_id"], + "title": body.get("title", ""), + "bg_mode": body.get("bg_mode", "backdrop"), + "backdrop_index": int(body.get("backdrop_index", 0)), + "text_color": body.get("text_color", "#FFFFFF"), + "logo_align": body.get("logo_align", "bottom-center"), + "logo_scale": float(body.get("logo_scale", 1.3)), + "darkness": float(body.get("darkness", 0.0)), + "studio": body.get("studio", "none"), + "studio_position": body.get("studio_position", "bottom-right"), + "new_episodes_tag": bool(body.get("new_episodes_tag", False)), + "season_finale_tag": bool(body.get("season_finale_tag", False)), + "generate_primary": bool(body.get("generate_primary", False)), + "logo_index": logo_index, + "primary_zoom": float(body.get("primary_zoom", 1.0)), + "primary_pan_x": float(body.get("primary_pan_x", 0.0)), + "primary_pan_y": float(body.get("primary_pan_y", -0.16)), + "thumb_zoom": float(body.get("thumb_zoom", 1.0)), + "thumb_pan_x": float(body.get("thumb_pan_x", 0.0)), + "thumb_pan_y": float(body.get("thumb_pan_y", 0.0)), + "upload_bg_id": body.get("upload_bg_id") or None, + } + + +async def get_logo_bytes_for_item( + item_id: str, + preferred_index: int | None, + *, + fallback_to_first: bool = False, +) -> bytes | None: + if preferred_index is not None and preferred_index < 0: + return None + if preferred_index is not None: + logo_bytes = await get_cached_emby_source_image(item_id, "Logo", preferred_index, optional=True) + if logo_bytes or not fallback_to_first or preferred_index == 0: + return logo_bytes + fallback_index = 0 if fallback_to_first else preferred_index + if fallback_index is not None and fallback_index < 0: + return None + return await get_cached_emby_source_image(item_id, "Logo", fallback_index, optional=True) + + +async def render_item_artwork(options: dict, *, fallback_logo_to_first: bool = False) -> tuple[bytes, bytes | None]: + poster_bytes = await get_cached_emby_source_image(options["item_id"], "Primary") + logo_bytes = await get_logo_bytes_for_item( + options["item_id"], + options["logo_index"], + fallback_to_first=fallback_logo_to_first, + ) + if options["bg_mode"] == "upload" and options["upload_bg_id"]: + upload_path = get_upload_cache_path(options["upload_bg_id"]) + backdrop_bytes = upload_path.read_bytes() + else: + backdrop_bytes = await get_cached_emby_source_image( + options["item_id"], + "Backdrop", + options["backdrop_index"], + optional=True, + ) + + effective_bg_mode = "backdrop" if options["bg_mode"] == "upload" else options["bg_mode"] + thumb_bytes = generate_thumbnail( + poster_bytes, + options["title"], + bg_mode=effective_bg_mode, + text_color=options["text_color"], + logo_align=options["logo_align"], + logo_scale=options["logo_scale"], + darkness=options["darkness"], + studio=options["studio"], + studio_position=options["studio_position"], + new_episodes_tag=options["new_episodes_tag"], + season_finale_tag=options["season_finale_tag"], + logo_index=options["logo_index"], + logo_bytes=logo_bytes, + backdrop_bytes=backdrop_bytes, + primary_zoom=options["primary_zoom"], + primary_pan_x=options["primary_pan_x"], + primary_pan_y=options["primary_pan_y"], + thumb_zoom=options["thumb_zoom"], + thumb_pan_x=options["thumb_pan_x"], + thumb_pan_y=options["thumb_pan_y"], + ) + + primary_bytes = None + if options["generate_primary"]: + try: + primary_bytes = generate_primary_cover( + poster_bytes, + options["title"], + bg_mode=effective_bg_mode, + text_color=options["text_color"], + logo_align=options["logo_align"], + logo_scale=options["logo_scale"], + darkness=options["darkness"], + studio=options["studio"], + studio_position=options["studio_position"], + new_episodes_tag=options["new_episodes_tag"], + season_finale_tag=options["season_finale_tag"], + logo_index=options["logo_index"], + logo_bytes=logo_bytes, + backdrop_bytes=backdrop_bytes, + primary_zoom=options["primary_zoom"], + primary_pan_x=options["primary_pan_x"], + primary_pan_y=options["primary_pan_y"], + ) + except Exception: + primary_bytes = None + return thumb_bytes, primary_bytes + + +def get_generator_cache_key(options: dict) -> str: + return build_cache_key( + item_id=options["item_id"], + bg_mode=options["bg_mode"], + backdrop_index=options["backdrop_index"], + text_color=options["text_color"], + logo_align=options["logo_align"], + logo_scale=options["logo_scale"], + darkness=options["darkness"], + studio=options["studio"], + studio_position=options["studio_position"], + new_episodes_tag=options["new_episodes_tag"], + season_finale_tag=options["season_finale_tag"], + logo_index=options["logo_index"], + primary_zoom=options["primary_zoom"], + primary_pan_x=options["primary_pan_x"], + primary_pan_y=options["primary_pan_y"], + thumb_zoom=options["thumb_zoom"], + thumb_pan_x=options["thumb_pan_x"], + thumb_pan_y=options["thumb_pan_y"], + upload_bg_id=options["upload_bg_id"], + ) + + def build_cache_key( item_id: str, bg_mode: str = "backdrop", backdrop_index: int = 0, - custom_bg: str | None = None, text_color: str = "#FFFFFF", logo_align: str = "bottom-center", logo_scale: float = 1.3, @@ -914,13 +930,16 @@ def build_cache_key( primary_zoom: float = 1.0, primary_pan_x: float = 0.0, primary_pan_y: float = -0.16, + thumb_zoom: float = 1.0, + thumb_pan_x: float = 0.0, + thumb_pan_y: float = 0.0, + upload_bg_id: str | None = None, ) -> str: payload = { "render_version": RENDER_VERSION, "item_id": item_id, "bg_mode": bg_mode, "backdrop_index": int(backdrop_index), - "custom_bg": custom_bg, "text_color": text_color, "logo_align": logo_align, "logo_scale": round(float(logo_scale), 4), @@ -933,6 +952,10 @@ def build_cache_key( "primary_zoom": round(float(primary_zoom), 4), "primary_pan_x": round(float(primary_pan_x), 4), "primary_pan_y": round(float(primary_pan_y), 4), + "thumb_zoom": round(float(thumb_zoom), 4), + "thumb_pan_x": round(float(thumb_pan_x), 4), + "thumb_pan_y": round(float(thumb_pan_y), 4), + "upload_bg_id": upload_bg_id, } return hashlib.md5(repr(payload).encode("utf-8")).hexdigest() @@ -949,7 +972,6 @@ def generate_thumbnail( poster_bytes: bytes, title: str, bg_mode: str = "backdrop", - custom_bg: str = None, text_color: str = "#FFFFFF", logo_align: str = "bottom-center", logo_scale: float = 1.3, @@ -966,6 +988,9 @@ def generate_thumbnail( primary_zoom: float = 1.0, primary_pan_x: float = 0.0, primary_pan_y: float = -0.16, + thumb_zoom: float = 1.0, + thumb_pan_x: float = 0.0, + thumb_pan_y: float = 0.0, ) -> bytes: # darkness is 0.0 (no dimming) → 1.0 (very dark); scale bg and vignette together @@ -975,7 +1000,7 @@ def generate_thumbnail( is_tall_layout = height >= int(width * 1.3) # --- Build background --- - if backdrop_bytes and bg_mode != "solid": + if backdrop_bytes: backdrop = load_image_from_bytes(backdrop_bytes, mode="RGB") if is_tall_layout: bg = build_tall_backdrop_background( @@ -988,14 +1013,8 @@ def generate_thumbnail( pan_y=primary_pan_y, ) else: - bg = cover_crop(backdrop, width, height) + bg = cover_crop_positioned(backdrop, width, height, zoom=thumb_zoom, pan_x=thumb_pan_x, pan_y=thumb_pan_y) bg = bg.point(lambda p: int(p * bg_dim)) - elif bg_mode == "solid" and custom_bg: - try: - bg_color = ImageColor.getrgb(custom_bg) - except ValueError: - bg_color = (15, 15, 25) - bg = Image.new("RGB", (width, height), bg_color) else: poster = load_image_from_bytes(poster_bytes, mode="RGB") if is_tall_layout: @@ -1010,7 +1029,7 @@ def generate_thumbnail( ) else: # Blurred poster fallback for landscape thumbs. - bg = cover_crop(poster, width, height) + bg = cover_crop_positioned(poster, width, height, zoom=thumb_zoom, pan_x=thumb_pan_x, pan_y=thumb_pan_y) bg = bg.filter(ImageFilter.GaussianBlur(radius=20)) bg = bg.point(lambda p: int(p * bg_dim * 0.85)) @@ -1127,15 +1146,15 @@ def generate_thumbnail( logo = logo.resize((lw, lh), Image.LANCZOS) logo_x, logo_y = logo_position(lw, lh) - # Drop shadow — blur only a tight crop around the logo, not the whole frame, - # to avoid a visible dark haze spreading across the backdrop. - blur_r = 5 + # Drop shadow — diffuse glow with no offset for a soft, professional look. + # Blur only a tight crop around the logo to avoid dark haze on the backdrop. + blur_r = 11 pad = blur_r * 3 _, _, _, a = logo.split() black = Image.new("RGB", (lw, lh), (0, 0, 0)) shadow_logo = Image.merge("RGBA", (*black.split()[:3], a)) shadow_crop = Image.new("RGBA", (lw + pad * 2, lh + pad * 2), (0, 0, 0, 0)) - shadow_crop.paste(shadow_logo, (pad + 4, pad + 4), shadow_logo) + shadow_crop.paste(shadow_logo, (pad, pad), shadow_logo) shadow_crop = shadow_crop.filter(ImageFilter.GaussianBlur(radius=blur_r)) bg = bg.convert("RGBA") bg.alpha_composite(shadow_crop, (logo_x - pad, logo_y - pad)) @@ -1193,7 +1212,6 @@ def generate_primary_cover( poster_bytes: bytes, title: str, bg_mode: str = "backdrop", - custom_bg: str | None = None, text_color: str = "#FFFFFF", logo_align: str = "bottom-center", logo_scale: float = 1.3, @@ -1209,11 +1227,12 @@ def generate_primary_cover( primary_pan_x: float = 0.0, primary_pan_y: float = -0.16, ) -> bytes: + # Use the same backdrop_bytes as the thumb (backdrop image or uploaded photo). + # When none is provided, falls back to using poster_bytes as background. return generate_thumbnail( poster_bytes, title, bg_mode=bg_mode, - custom_bg=custom_bg, text_color=text_color, logo_align=logo_align, logo_scale=logo_scale, @@ -1233,6 +1252,328 @@ def generate_primary_cover( ) +def normalize_collection_target_type(value: str | None) -> str: + normalized = (value or "Thumb").strip().lower() + if normalized == "primary": + return "Primary" + if normalized == "thumb": + return "Thumb" + raise HTTPException(status_code=400, detail="Invalid target type.") + + +def normalize_collection_choice(value: str | None, allowed: set[str], default: str) -> str: + normalized = (value or default).strip().lower() + if normalized not in allowed: + return default + return normalized + + +def normalize_collection_text(value: str | None) -> str: + text = " ".join((value or "").split()) + if not text: + raise HTTPException(status_code=400, detail="Text is required.") + return text[:140] + + +def clamp_float(value: float, minimum: float, maximum: float) -> float: + return max(minimum, min(maximum, float(value))) + + +def get_collection_canvas_size(target_type: str) -> tuple[int, int]: + return (PRIMARY_WIDTH, PRIMARY_HEIGHT) if target_type == "Primary" else (THUMB_WIDTH, THUMB_HEIGHT) + + +def build_collection_cache_key( + *, + item_id: str, + target_type: str, + text: str, + text_color: str, + text_align: str, + text_position: str, + text_scale: float, + darkness: float, + zoom: float, + pan_x: float, + pan_y: float, + upload_bg_id: str | None, +) -> str: + payload = { + "render_version": COLLECTION_RENDER_VERSION, + "item_id": item_id, + "target_type": target_type, + "text": text, + "text_color": text_color, + "text_align": text_align, + "text_position": text_position, + "text_scale": round(float(text_scale), 4), + "darkness": round(float(darkness), 4), + "zoom": round(float(zoom), 4), + "pan_x": round(float(pan_x), 4), + "pan_y": round(float(pan_y), 4), + "upload_bg_id": upload_bg_id, + } + return hashlib.md5(repr(payload).encode("utf-8")).hexdigest() + + +def get_collection_cache_path(cache_key: str) -> Path: + return CACHE_DIR / f"{cache_key}.collection.png" + + +def build_collection_fallback_background(width: int, height: int) -> Image.Image: + bg = Image.new("RGB", (width, height), "#121723") + draw = ImageDraw.Draw(bg) + for y in range(height): + t = y / max(1, height - 1) + r = int(18 + (28 * t)) + g = int(24 + (18 * t)) + b = int(35 + (48 * t)) + draw.line([(0, y), (width, y)], fill=(r, g, b)) + accent_radius = max(width, height) // 2 + accent = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + accent_draw = ImageDraw.Draw(accent) + accent_draw.ellipse( + ( + width - accent_radius, + -accent_radius // 3, + width + accent_radius // 2, + accent_radius + accent_radius // 6, + ), + fill=(74, 111, 255, 50), + ) + accent_draw.ellipse( + ( + -accent_radius // 2, + height - accent_radius, + accent_radius, + height + accent_radius // 3, + ), + fill=(52, 211, 153, 34), + ) + return Image.alpha_composite(bg.convert("RGBA"), accent).convert("RGB") + + +def fit_collection_text_block( + text: str, + *, + width: int, + height: int, + text_scale: float, +) -> tuple[ImageFont.FreeTypeFont, list[str], list[tuple[int, int, int, int]], int]: + is_tall = height > width + max_width = width - int(width * 0.12) + max_height = int(height * (0.54 if is_tall else 0.42)) + max_lines = 5 if is_tall else 3 + base_size = int(height * (0.07 if is_tall else 0.12) * text_scale) + min_size = 34 if is_tall else 24 + + size = max(base_size, min_size) + while size >= min_size: + font = get_arial_bold_font(size) + lines = wrap_text(text, font, max_width) + boxes = [font.getbbox(line) for line in lines] if lines else [] + heights = [(box[3] - box[1]) for box in boxes] + line_gap = max(8, int(size * 0.16)) + total_height = sum(heights) + line_gap * max(0, len(lines) - 1) + if lines and len(lines) <= max_lines and total_height <= max_height: + return font, lines, boxes, line_gap + size -= 4 + + font = get_arial_bold_font(min_size) + lines = wrap_text(text, font, max_width) + boxes = [font.getbbox(line) for line in lines] if lines else [] + line_gap = max(8, int(min_size * 0.16)) + return font, lines or [text], boxes or [font.getbbox(text)], line_gap + + +def generate_collection_art( + *, + source_bytes: bytes | None, + target_type: str, + text: str, + text_color: str, + text_align: str, + text_position: str, + text_scale: float, + darkness: float, + zoom: float, + pan_x: float, + pan_y: float, +) -> bytes: + width, height = get_collection_canvas_size(target_type) + darkness = clamp_float(darkness, 0.0, 1.0) + zoom = clamp_float(zoom, PRIMARY_MIN_ZOOM, PRIMARY_MAX_ZOOM) + pan_x = clamp_float(pan_x, -1.0, 1.0) + pan_y = clamp_float(pan_y, -1.0, 1.0) + + if source_bytes: + source = load_image_from_bytes(source_bytes, mode="RGB") + bg = cover_crop_positioned(source, width, height, zoom=zoom, pan_x=pan_x, pan_y=pan_y) + else: + bg = build_collection_fallback_background(width, height) + + if darkness > 0: + bg = bg.point(lambda p: int(p * (1.0 - (darkness * 0.42)))) + + font, lines, boxes, line_gap = fit_collection_text_block( + text, + width=width, + height=height, + text_scale=clamp_float(text_scale, 0.65, 1.8), + ) + try: + text_fill = ImageColor.getrgb(text_color) + except ValueError: + text_fill = (255, 255, 255) + padding_x = max(34, int(width * 0.06)) + padding_y = max(28, int(height * 0.05)) + line_heights = [(box[3] - box[1]) for box in boxes] + line_widths = [(box[2] - box[0]) for box in boxes] + text_block_w = max(line_widths) if line_widths else 0 + text_block_h = sum(line_heights) + line_gap * max(0, len(lines) - 1) + + if text_align == "left": + text_x = padding_x + elif text_align == "right": + text_x = width - padding_x - text_block_w + else: + text_x = (width - text_block_w) // 2 + + if text_position == "top": + text_y = padding_y + elif text_position == "center": + text_y = max(padding_y, (height - text_block_h) // 2) + else: + text_y = height - padding_y - text_block_h + + panel_pad_x = max(22, int(font.size * 0.45)) + panel_pad_y = max(18, int(font.size * 0.34)) + panel_left = max(14, text_x - panel_pad_x) + panel_top = max(14, text_y - panel_pad_y) + panel_right = min(width - 14, text_x + text_block_w + panel_pad_x) + panel_bottom = min(height - 14, text_y + text_block_h + panel_pad_y) + + overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + overlay_draw = ImageDraw.Draw(overlay) + overlay_draw.rounded_rectangle( + [(panel_left, panel_top), (panel_right, panel_bottom)], + radius=max(18, int(min(width, height) * 0.024)), + fill=(0, 0, 0, 92 + int(darkness * 80)), + outline=(255, 255, 255, 18), + width=1, + ) + + bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB") + draw = ImageDraw.Draw(bg) + stroke_width = max(2, int(font.size * 0.08)) + current_y = text_y + for line, box, line_height, line_width in zip(lines, boxes, line_heights, line_widths): + if text_align == "left": + line_x = padding_x - box[0] + elif text_align == "right": + line_x = width - padding_x - line_width - box[0] + else: + line_x = ((width - line_width) // 2) - box[0] + draw.text( + (line_x, current_y - box[1]), + line, + font=font, + fill=text_fill, + stroke_width=stroke_width, + stroke_fill=(0, 0, 0), + ) + current_y += line_height + line_gap + + buf = io.BytesIO() + bg.save(buf, format="PNG") + buf.seek(0) + return buf.getvalue() + + +def parse_collection_render_request(body: dict) -> dict: + try: + item_id = str(body["item_id"]) + except KeyError as exc: + raise HTTPException(status_code=400, detail="Missing item id.") from exc + + return { + "item_id": item_id, + "target_type": normalize_collection_target_type(body.get("target_type", "Thumb")), + "text": normalize_collection_text(body.get("text") or body.get("title")), + "text_color": body.get("text_color", "#FFFFFF"), + "text_align": normalize_collection_choice(body.get("text_align"), {"left", "center", "right"}, "center"), + "text_position": normalize_collection_choice(body.get("text_position"), {"top", "center", "bottom"}, "bottom"), + "text_scale": clamp_float(body.get("text_scale", 1.0), 0.65, 1.8), + "darkness": clamp_float(body.get("darkness", 0.18), 0.0, 0.85), + "zoom": clamp_float(body.get("zoom", 1.0), PRIMARY_MIN_ZOOM, PRIMARY_MAX_ZOOM), + "pan_x": clamp_float(body.get("pan_x", 0.0), -1.0, 1.0), + "pan_y": clamp_float(body.get("pan_y", 0.0), -1.0, 1.0), + "upload_bg_id": body.get("upload_bg_id") or None, + } + + +async def get_collection_source_bytes(item_id: str, target_type: str, upload_bg_id: str | None) -> bytes | None: + if upload_bg_id: + return get_upload_cache_path(upload_bg_id).read_bytes() + + fallback_types = [target_type] + if target_type == "Thumb": + fallback_types.append("Primary") + else: + fallback_types.append("Thumb") + + seen: set[str] = set() + for image_type in fallback_types: + if image_type in seen: + continue + seen.add(image_type) + image_bytes = await emby_get_image_optional(item_id, image_type) + if image_bytes: + return image_bytes + return None + + +async def render_collection_art_preview(options: dict) -> tuple[str, bytes]: + cache_key = build_collection_cache_key( + item_id=options["item_id"], + target_type=options["target_type"], + text=options["text"], + text_color=options["text_color"], + text_align=options["text_align"], + text_position=options["text_position"], + text_scale=options["text_scale"], + darkness=options["darkness"], + zoom=options["zoom"], + pan_x=options["pan_x"], + pan_y=options["pan_y"], + upload_bg_id=options["upload_bg_id"], + ) + cache_path = get_collection_cache_path(cache_key) + if cache_path.exists(): + return cache_key, cache_path.read_bytes() + + source_bytes = await get_collection_source_bytes( + options["item_id"], + options["target_type"], + options["upload_bg_id"], + ) + preview_bytes = generate_collection_art( + source_bytes=source_bytes, + target_type=options["target_type"], + text=options["text"], + text_color=options["text_color"], + text_align=options["text_align"], + text_position=options["text_position"], + text_scale=options["text_scale"], + darkness=options["darkness"], + zoom=options["zoom"], + pan_x=options["pan_x"], + pan_y=options["pan_y"], + ) + cache_path.write_bytes(preview_bytes) + return cache_key, preview_bytes + + # --- API Routes --- @app.get("/", response_class=HTMLResponse) @@ -1240,6 +1581,11 @@ async def index(request: Request): return templates.TemplateResponse(request, "index.html") +@app.get("/collections", response_class=HTMLResponse) +async def collections_page(request: Request): + return templates.TemplateResponse(request, "collections.html") + + @app.get("/airing", response_class=HTMLResponse) async def airing_page(request: Request): return templates.TemplateResponse(request, "airing.html") @@ -1304,7 +1650,6 @@ async def apply_new_season_banner(request: Request): item_id=item_id, bg_mode="backdrop", backdrop_index=0, - custom_bg=None, text_color="#FFFFFF", logo_align="bottom-center", logo_scale=1.3, @@ -1354,7 +1699,6 @@ async def apply_new_season_banner(request: Request): season_finale_tag=False, logo_index=0, logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, ) primary_cache_path.write_bytes(primary_bytes) @@ -1372,7 +1716,7 @@ async def apply_new_season_banner(request: Request): "status": "applied", "thumb_code": thumb_status, "primary_code": primary_status, - "primary_attempted": generate_primary, + "primary_attempted": options["generate_primary"], "primary_error": primary_error, } @@ -1417,6 +1761,87 @@ async def search_items( } +@app.get("/api/categories") +async def get_categories(): + data = await emby_get("/Genres", { + "Recursive": "true", + "SortBy": "SortName", + "SortOrder": "Ascending", + }) + items = [] + for item in data.get("Items", []): + items.append({ + "id": item.get("Id"), + "name": item.get("Name", ""), + "item_count": item.get("ItemCount") or item.get("ChildCount") or 0, + }) + items.sort(key=lambda entry: entry["name"].lower()) + return {"items": items} + + +@app.get("/api/collections") +async def get_collections( + q: str = Query(""), + start: int = Query(0, ge=0), + limit: int = Query(24, ge=1, le=60), +): + params = { + "IncludeItemTypes": "BoxSet", + "Recursive": "true", + "SortBy": "SortName", + "SortOrder": "Ascending", + "Fields": "ChildCount", + "StartIndex": str(start), + "Limit": str(limit), + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Thumb", + } + if q.strip(): + params["SearchTerm"] = q.strip() + + data = await emby_get("/Items", params) + items = [] + for item in data.get("Items", []): + image_tags = item.get("ImageTags") or {} + items.append({ + "id": item["Id"], + "name": item.get("Name", ""), + "type": item.get("Type", ""), + "child_count": item.get("ChildCount") or 0, + "poster_url": f"/api/poster/{item['Id']}?w=72&h=108&q=72" if "Primary" in image_tags else None, + }) + + total = int(data.get("TotalRecordCount", start + len(items))) + return { + "items": items, + "start": start, + "limit": limit, + "total": total, + "has_more": start + len(items) < total, + } + + +@app.get("/api/collections/{item_id}/artwork") +async def get_collection_artwork(item_id: str): + images = await emby_get(f"/Items/{item_id}/Images") + artwork = { + "Primary": {"exists": False, "url": None}, + "Thumb": {"exists": False, "url": None}, + } + for image in images: + image_type = image.get("ImageType") + if image_type not in artwork or artwork[image_type]["exists"]: + continue + artwork[image_type] = { + "exists": True, + "url": f"/api/item-image/{item_id}?type={image_type}&w={300 if image_type == 'Primary' else 420}&h={450 if image_type == 'Primary' else 236}&q=88", + } + return { + "primary": artwork["Primary"], + "thumb": artwork["Thumb"], + } + + @app.get("/api/images/{item_id}") async def get_image_info(item_id: str): """Returns accurate logo and backdrop counts by querying the Images endpoint directly.""" @@ -1513,267 +1938,87 @@ async def get_cached_primary_preview(cache_key: str): ) -@app.get("/api/artwork/providers") -async def list_artwork_search_providers(): - return { - "providers": [ - {"key": provider.key, "label": provider.label} - for provider in IMAGE_SEARCH_PROVIDERS.values() - ], - "targets": [ - { - "key": key, - "label": value["label"], - "width": value["width"], - "height": value["height"], - "emby_type": value["emby_type"], - } - for key, value in EDITOR_TARGETS.items() - ], - } - - -@app.get("/api/artwork/search") -async def search_external_artwork( - q: str = Query(..., min_length=2), - provider: str = Query("wikimedia"), - limit: int = Query(12, ge=1, le=24), -): - search_provider = IMAGE_SEARCH_PROVIDERS.get(provider) - if search_provider is None: - raise HTTPException(status_code=400, detail="Unknown image search provider.") - try: - results = await search_provider.search(q, limit=limit) - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail=f"Image search failed: {exc}") from exc - return { - "provider": provider, - "items": [result.__dict__ for result in results], - } - - -@app.post("/api/artwork/import") -async def import_remote_artwork(request: Request): - body = await request.json() - image_url = body.get("image_url", "") - if not image_url: - raise HTTPException(status_code=400, detail="Missing image URL.") - parsed = urllib.parse.urlparse(image_url) - if parsed.scheme not in {"http", "https"}: - raise HTTPException(status_code=400, detail="Only http and https image URLs are supported.") - - client = get_http_client() - try: - r = await client.get(image_url, follow_redirects=True) - r.raise_for_status() - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail=f"Could not import remote image: {exc}") from exc - - content_type = r.headers.get("content-type", "").split(";")[0].lower() - if content_type and not content_type.startswith("image/"): - raise HTTPException(status_code=400, detail="Remote URL did not return an image.") - image_bytes = r.content - if len(image_bytes) > REMOTE_IMAGE_MAX_BYTES: - raise HTTPException(status_code=400, detail="Remote image is too large.") - width, height, fmt = validate_image_bytes(image_bytes) - import_id = hashlib.sha256(image_bytes).hexdigest() - import_path = IMPORT_CACHE_DIR / f"{import_id}.img" - if not import_path.exists(): - import_path.write_bytes(image_bytes) - return { - "id": import_id, - "width": width, - "height": height, - "format": fmt, - "preview_url": f"/api/artwork/import/{import_id}", - } - - -@app.get("/api/artwork/import/{import_id}") -async def get_imported_artwork(import_id: str): - image_bytes = get_import_cache_path(import_id).read_bytes() - return Response( - content=image_bytes, - media_type="image/jpeg", - headers={"Cache-Control": "public, max-age=86400"}, - ) - - -@app.post("/api/artwork/export") -async def export_artwork(request: Request): - body = await request.json() - source_bytes = await resolve_editor_source_bytes(body) - cache_key = build_editor_cache_key(body) - cache_path = CACHE_DIR / f"{cache_key}.editor.jpg" - if not cache_path.exists(): - cache_path.write_bytes(render_artwork_editor_image( - source_bytes, - target_mode=body.get("target_mode", "poster"), - fit_mode=body.get("fit_mode", "cover"), - fill_mode=body.get("fill_mode", "blur"), - fill_color=body.get("fill_color", "#101318"), - zoom=float(body.get("zoom", 1.0)), - pan_x=float(body.get("pan_x", 0.0)), - pan_y=float(body.get("pan_y", 0.0)), - quality=int(body.get("quality", 95)), - )) - target = get_editor_target(body.get("target_mode", "poster")) - return Response( - content=cache_path.read_bytes(), - media_type="image/jpeg", - headers={ - "Cache-Control": "no-store", - "X-Editor-Cache-Key": cache_key, - "X-Editor-Width": str(target["width"]), - "X-Editor-Height": str(target["height"]), - "X-Editor-Emby-Type": target["emby_type"], - }, - ) - - -@app.get("/api/artwork/export/{cache_key}") -async def get_exported_artwork(cache_key: str): +@app.get("/api/cache/{cache_key}/collection") +async def get_cached_collection_preview(cache_key: str): if not cache_key or any(ch not in "0123456789abcdef" for ch in cache_key.lower()): - raise HTTPException(status_code=400, detail="Invalid export cache key.") - cache_path = CACHE_DIR / f"{cache_key}.editor.jpg" + raise HTTPException(status_code=400, detail="Invalid cache key.") + cache_path = get_collection_cache_path(cache_key) if not cache_path.exists(): - raise HTTPException(status_code=404, detail="Export was not found in cache.") + raise HTTPException(status_code=404, detail="Collection preview not found.") return Response( content=cache_path.read_bytes(), - media_type="image/jpeg", + media_type="image/png", headers={"Cache-Control": "no-store"}, ) -@app.post("/api/artwork/apply") -async def apply_artwork_export(request: Request): +@app.post("/api/upload-background") +async def upload_background(request: Request): + form = await request.form() + file = form.get("file") + if file is None: + raise HTTPException(status_code=400, detail="Missing file field.") + image_bytes = await file.read() + if len(image_bytes) > UPLOAD_MAX_BYTES: + raise HTTPException(status_code=400, detail="Uploaded file is too large (max 40 MB).") + width, height, fmt = validate_image_bytes(image_bytes) + upload_id = hashlib.sha256(image_bytes).hexdigest() + upload_path = IMPORT_CACHE_DIR / f"{upload_id}.img" + if not upload_path.exists(): + upload_path.write_bytes(image_bytes) + return {"upload_id": upload_id, "width": width, "height": height, "format": fmt} + + +@app.post("/api/collections/generate") +async def generate_collection_preview(request: Request): body = await request.json() - item_id = body.get("item_id") - if not item_id: - raise HTTPException(status_code=400, detail="Missing item id.") - target = get_editor_target(body.get("target_mode", "poster")) - cache_key = body.get("cache_key") or build_editor_cache_key(body) - cache_path = CACHE_DIR / f"{cache_key}.editor.jpg" - if cache_path.exists(): - image_bytes = cache_path.read_bytes() - else: - source_bytes = await resolve_editor_source_bytes(body) - image_bytes = render_artwork_editor_image( - source_bytes, - target_mode=body.get("target_mode", "poster"), - fit_mode=body.get("fit_mode", "cover"), - fill_mode=body.get("fill_mode", "blur"), - fill_color=body.get("fill_color", "#101318"), - zoom=float(body.get("zoom", 1.0)), - pan_x=float(body.get("pan_x", 0.0)), - pan_y=float(body.get("pan_y", 0.0)), - quality=int(body.get("quality", 95)), - ) - cache_path.write_bytes(image_bytes) - status = await emby_upload_image(item_id, image_bytes, target["emby_type"]) + options = parse_collection_render_request(body) + cache_key, preview_bytes = await render_collection_art_preview(options) + return StreamingResponse( + io.BytesIO(preview_bytes), + media_type="image/png", + headers={"X-Cache-Key": cache_key}, + ) + + +@app.post("/api/collections/apply") +async def apply_collection_art(request: Request): + body = await request.json() + options = parse_collection_render_request(body) + _, preview_bytes = await render_collection_art_preview(options) + status_code = await emby_upload_image(options["item_id"], preview_bytes, options["target_type"]) return { "status": "applied", - "emby_type": target["emby_type"], - "code": status, - "cache_key": cache_key, + "target_type": options["target_type"], + "code": status_code, } @app.post("/api/generate") async def generate(request: Request): - body = await request.json() - item_id = body["item_id"] - title = body["title"] - bg_mode = body.get("bg_mode", "backdrop") - custom_bg = body.get("custom_bg", None) - text_color = body.get("text_color", "#FFFFFF") - logo_align = body.get("logo_align", "bottom-center") - logo_scale = float(body.get("logo_scale", 1.3)) - darkness = float(body.get("darkness", 0.0)) - studio = body.get("studio", "none") - studio_position = body.get("studio_position", "bottom-right") - new_episodes_tag = bool(body.get("new_episodes_tag", False)) - season_finale_tag = bool(body.get("season_finale_tag", False)) - generate_primary = bool(body.get("generate_primary", False)) - logo_index_raw = body.get("logo_index", None) - logo_index = int(logo_index_raw) if logo_index_raw is not None else None - backdrop_index = int(body.get("backdrop_index", 0)) - primary_zoom = float(body.get("primary_zoom", 1.0)) - primary_pan_x = float(body.get("primary_pan_x", 0.0)) - primary_pan_y = float(body.get("primary_pan_y", -0.16)) - - poster_bytes = await emby_get_image(item_id, "Primary") - logo_bytes = await emby_get_image_optional(item_id, "Logo", logo_index) - backdrop_bytes = await emby_get_image_optional(item_id, "Backdrop", backdrop_index) - - thumb_bytes = generate_thumbnail( - poster_bytes, title, - bg_mode=bg_mode, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) - - cache_key = build_cache_key( - item_id=item_id, - bg_mode=bg_mode, - backdrop_index=backdrop_index, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) + options = parse_generator_request(await request.json()) + cache_key = get_generator_cache_key(options) thumb_cache_path = get_thumb_cache_path(cache_key) + primary_cache_path = get_primary_cache_path(cache_key) + + thumb_cached = thumb_cache_path.exists() + primary_cached = primary_cache_path.exists() + if thumb_cached and (not options["generate_primary"] or primary_cached): + return StreamingResponse( + io.BytesIO(thumb_cache_path.read_bytes()), + media_type="image/png", + headers={ + "X-Primary-Generated": "1" if (options["generate_primary"] and primary_cached) else "0", + "X-Cache-Key": cache_key, + }, + ) + + thumb_bytes, primary_bytes = await render_item_artwork(options) thumb_cache_path.write_bytes(thumb_bytes) - primary_generated = False - if generate_primary: - try: - primary_bytes = generate_primary_cover( - poster_bytes, - title, - bg_mode=bg_mode, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) - get_primary_cache_path(cache_key).write_bytes(primary_bytes) - primary_generated = True - except Exception: - primary_generated = False + primary_generated = primary_bytes is not None + if primary_generated: + get_primary_cache_path(cache_key).write_bytes(primary_bytes) return StreamingResponse( io.BytesIO(thumb_bytes), @@ -1787,135 +2032,32 @@ async def generate(request: Request): @app.post("/api/apply") async def apply_to_emby(request: Request): - body = await request.json() - item_id = body["item_id"] - title = body.get("title", "") - bg_mode = body.get("bg_mode", "backdrop") - backdrop_index = int(body.get("backdrop_index", 0)) - custom_bg = body.get("custom_bg", None) - text_color = body.get("text_color", "#FFFFFF") - logo_align = body.get("logo_align", "bottom-center") - logo_scale = float(body.get("logo_scale", 1.3)) - darkness = float(body.get("darkness", 0.0)) - studio = body.get("studio", "none") - studio_position = body.get("studio_position", "bottom-right") - new_episodes_tag = bool(body.get("new_episodes_tag", False)) - season_finale_tag = bool(body.get("season_finale_tag", False)) - generate_primary = bool(body.get("generate_primary", False)) - logo_index_raw = body.get("logo_index", None) - logo_index = int(logo_index_raw) if logo_index_raw is not None else None - primary_zoom = float(body.get("primary_zoom", 1.0)) - primary_pan_x = float(body.get("primary_pan_x", 0.0)) - primary_pan_y = float(body.get("primary_pan_y", -0.16)) - - cache_key = build_cache_key( - item_id=item_id, - bg_mode=bg_mode, - backdrop_index=backdrop_index, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) + options = parse_generator_request(await request.json()) + cache_key = get_generator_cache_key(options) thumb_cache_path = get_thumb_cache_path(cache_key) primary_cache_path = get_primary_cache_path(cache_key) if not thumb_cache_path.exists(): - poster_bytes = await emby_get_image(item_id, "Primary") - logo_bytes = await emby_get_image_optional(item_id, "Logo", logo_index) - backdrop_bytes = await emby_get_image_optional(item_id, "Backdrop", backdrop_index) - thumb_bytes = generate_thumbnail( - poster_bytes, - title or "", - bg_mode=bg_mode, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) + thumb_bytes, primary_bytes = await render_item_artwork(options) thumb_cache_path.write_bytes(thumb_bytes) - if generate_primary and not primary_cache_path.exists(): - try: - primary_cache_path.write_bytes( - generate_primary_cover( - poster_bytes, - title or "", - bg_mode=bg_mode, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) - ) - except Exception: - pass + if primary_bytes is not None and not primary_cache_path.exists(): + primary_cache_path.write_bytes(primary_bytes) thumb_bytes = thumb_cache_path.read_bytes() - thumb_status = await emby_upload_image(item_id, thumb_bytes, "Thumb") + thumb_status = await emby_upload_image(options["item_id"], thumb_bytes, "Thumb") primary_status = None primary_error = None - if generate_primary: + if options["generate_primary"]: try: if primary_cache_path.exists(): primary_bytes = primary_cache_path.read_bytes() else: - poster_bytes = await emby_get_image(item_id, "Primary") - logo_bytes = await emby_get_image_optional(item_id, "Logo", logo_index) - backdrop_bytes = await emby_get_image_optional(item_id, "Backdrop", backdrop_index) - primary_bytes = generate_primary_cover( - poster_bytes, - title or "", - bg_mode=bg_mode, - custom_bg=custom_bg, - text_color=text_color, - logo_align=logo_align, - logo_scale=logo_scale, - darkness=darkness, - studio=studio, - studio_position=studio_position, - new_episodes_tag=new_episodes_tag, - season_finale_tag=season_finale_tag, - logo_index=logo_index, - logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, - primary_zoom=primary_zoom, - primary_pan_x=primary_pan_x, - primary_pan_y=primary_pan_y, - ) + _, primary_bytes = await render_item_artwork(options) + if primary_bytes is None: + raise HTTPException(status_code=500, detail="Primary artwork could not be generated.") primary_cache_path.write_bytes(primary_bytes) - primary_status = await emby_upload_image(item_id, primary_bytes, "Primary") + primary_status = await emby_upload_image(options["item_id"], primary_bytes, "Primary") except Exception as exc: primary_error = str(exc) @@ -1923,11 +2065,85 @@ async def apply_to_emby(request: Request): "status": "applied", "thumb_code": thumb_status, "primary_code": primary_status, - "primary_attempted": generate_primary, + "primary_attempted": options["generate_primary"], "primary_error": primary_error, } +@app.post("/api/bulk-apply/category") +async def bulk_apply_category(request: Request): + body = await request.json() + category_id = (body.get("category_id") or "").strip() + category_name = (body.get("category_name") or "").strip() + if not category_id: + raise HTTPException(status_code=400, detail="Category is required.") + + base_options = parse_generator_request(body) + items = await emby_get_all("/Items", { + "GenreIds": category_id, + "IncludeItemTypes": "Movie,Series", + "Recursive": "true", + "SortBy": "SortName", + "SortOrder": "Ascending", + "Fields": "ProductionYear", + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + }) + eligible_items: list[dict] = [] + skipped_without_logo: list[str] = [] + skipped_without_primary: list[str] = [] + for item in items: + image_tags = item.get("ImageTags") or {} + name = item.get("Name") or "Unknown" + if "Primary" not in image_tags: + skipped_without_primary.append(name) + continue + if "Logo" not in image_tags: + skipped_without_logo.append(name) + continue + eligible_items.append(item) + + applied: list[str] = [] + failed: list[dict] = [] + for item in eligible_items: + item_options = dict(base_options) + item_options["item_id"] = item["Id"] + item_options["title"] = item.get("Name", "") + if item.get("Type") != "Series": + item_options["new_episodes_tag"] = False + item_options["season_finale_tag"] = False + cache_key = get_generator_cache_key(item_options) + thumb_cache_path = get_thumb_cache_path(cache_key) + primary_cache_path = get_primary_cache_path(cache_key) + try: + thumb_bytes, primary_bytes = await render_item_artwork(item_options, fallback_logo_to_first=True) + thumb_cache_path.write_bytes(thumb_bytes) + await emby_upload_image(item["Id"], thumb_bytes, "Thumb") + if item_options["generate_primary"] and primary_bytes is not None: + primary_cache_path.write_bytes(primary_bytes) + await emby_upload_image(item["Id"], primary_bytes, "Primary") + applied.append(item.get("Name", "")) + except Exception as exc: + failed.append({ + "name": item.get("Name", ""), + "error": str(exc), + }) + + return { + "status": "completed", + "category_id": category_id, + "category_name": category_name, + "matched_count": len(items), + "eligible_count": len(eligible_items), + "applied_count": len(applied), + "skipped_without_logo_count": len(skipped_without_logo), + "skipped_without_primary_count": len(skipped_without_primary), + "failed_count": len(failed), + "failed": failed[:12], + "applied": applied[:12], + } + + @app.get("/api/config") async def get_config(): return { diff --git a/docker-compose.yml b/docker-compose.yml index 1739a14..da34e5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,11 @@ services: environment: - EMBY_URL=http://your-emby-server:8096 - EMBY_API_KEY=your-api-key-here + # Optional external artwork providers: + # - TMDB_BEARER_TOKEN=your-tmdb-read-access-token + # - TMDB_API_KEY=your-tmdb-v3-api-key + # - GOOGLE_CUSTOM_SEARCH_API_KEY=your-google-api-key + # - GOOGLE_CUSTOM_SEARCH_ENGINE_ID=your-google-search-engine-id volumes: - ./output:/app/output - ./cache:/app/cache diff --git a/emby-cover.code-workspace b/emby-cover.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/emby-cover.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/templates/airing.html b/templates/airing.html index d5ad7bc..57c8a93 100644 --- a/templates/airing.html +++ b/templates/airing.html @@ -387,6 +387,7 @@

Emby Thumbnail Generator

diff --git a/templates/collections.html b/templates/collections.html new file mode 100644 index 0000000..9252c91 --- /dev/null +++ b/templates/collections.html @@ -0,0 +1,1786 @@ + + + + + +Emby Collection Artwork + + + + + + +
+ +

Emby Thumbnail Generator

+ +
+ + Checking… +
+
+ +
+ + +
+
+
+

Collection Artwork

+

Select an Emby collection, switch between `Thumb` and `Primary`, then use either the current artwork or a dropped image as the base. The editor lets you crop, darken, and overlay custom text before pushing the result back to Emby.

+
+
Waiting for collection
+
+ +
+
+
+

Collection

+ Editing Thumb +
+
+
+
Pick a collection
+
+ BoxSet + No selection yet +
+
+ +
+ + + +
+ +
+
+
Upload or drop a base image
+

Drop a JPG, PNG, or WEBP here to use it as the background for the current target. If you clear it, the editor falls back to the collection’s existing artwork.

+
+
Using current artwork when available.
+
+ + +
+ +
+
+
+ +
+
+
+
+ Preview + Select a collection to start editing. +
+
100% · X 0 · Y 0
+
+ +
+
+ +
Select a collection from the left to build custom artwork.
+
+ +
+ +
+ +
+
+ + +
+
Drag to position • Wheel to zoom
+
+
+
+
+ +
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + 100% +
+
+ +
+ +
+ + 18% +
+
+ +
+ +
+ + + + 100% +
+
+ +
+ +
+ + 0 +
+
+ +
+ +
+ + 0 +
+
+
+ +
+ + + +
+
+
+
+
+
+ +
+ + + + diff --git a/templates/index.html b/templates/index.html index d690511..6796350 100644 --- a/templates/index.html +++ b/templates/index.html @@ -362,193 +362,25 @@ white-space: nowrap; } - .artwork-editor { - margin: 0 clamp(16px, 2.4vw, 28px) clamp(14px, 2vw, 22px); - border: 1px solid var(--border); - border-radius: var(--r-lg); - background: linear-gradient(135deg, rgba(26,32,48,0.92), rgba(13,15,18,0.94)); - box-shadow: 0 18px 60px rgba(0,0,0,0.35); + .upload-bg-group { display: none; - overflow: hidden; - } - .artwork-editor.visible { display: block; } - .editor-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .editor-title { - font-size: 13px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; - } - .editor-subtitle { - margin-top: 3px; - color: var(--text-2); - font-size: 12px; - } - .editor-grid { - display: grid; - grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.2fr) minmax(220px, 0.9fr); - gap: 14px; - padding: 14px; - } - .editor-panel { - border: 1px solid var(--border); - border-radius: var(--r); - background: rgba(13,15,18,0.54); - padding: 12px; - } - .editor-panel h3 { - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-2); - margin-bottom: 10px; - } - .editor-source-list { - display: grid; - gap: 8px; - } - .editor-source-btn { - border: 1px solid var(--border); - border-radius: 9px; - padding: 9px 10px; - background: var(--surface2); - color: var(--text); - font: inherit; - font-size: 12px; - text-align: left; - cursor: pointer; - } - .editor-source-btn.active { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); - } - .editor-canvas-wrap { - display: grid; - place-items: center; - min-height: 360px; - } - .editor-canvas { - position: relative; - width: min(100%, 330px); - aspect-ratio: 2 / 3; - border-radius: 16px; - overflow: hidden; - background: #07080a; - border: 1px solid rgba(255,255,255,0.08); - box-shadow: 0 20px 70px rgba(0,0,0,0.55); - cursor: grab; - touch-action: none; - } - .editor-canvas.thumb, - .editor-canvas.backdrop { aspect-ratio: 16 / 9; width: min(100%, 470px); } - .editor-canvas.dragging { cursor: grabbing; } - .editor-canvas img { - width: 100%; - height: 100%; - object-fit: contain; - display: block; - } - .editor-canvas-empty { - position: absolute; - inset: 0; - display: grid; - place-items: center; - color: var(--text-3); - font-size: 12px; - text-align: center; - padding: 20px; - } - .editor-row { - display: grid; gap: 6px; - margin-bottom: 10px; - } - .editor-row label { - color: var(--text-2); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - } - .editor-row select, - .editor-row input[type="text"], - .editor-row input[type="color"] { - width: 100%; + align-items: center; + margin-top: 5px; + padding: 5px 8px; + background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; - background: var(--surface2); - color: var(--text); - padding: 8px; - font: inherit; - font-size: 12px; } - .editor-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - } - .editor-note { - color: var(--text-2); - font-size: 12px; - line-height: 1.45; - } - .modal-backdrop { - position: fixed; - inset: 0; - z-index: 500; - display: none; - place-items: center; - background: rgba(0,0,0,0.72); - padding: 22px; - } - .modal-backdrop.visible { display: grid; } - .modal-card { - width: min(920px, 100%); - max-height: min(760px, 92vh); - overflow: auto; - border: 1px solid var(--border); - border-radius: 18px; - background: var(--surface); - box-shadow: 0 24px 90px rgba(0,0,0,0.6); - } - .modal-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 16px; - border-bottom: 1px solid var(--border); - } - .modal-body { padding: 16px; } - .image-search-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 12px; - margin-top: 14px; - } - .image-result { - border: 1px solid var(--border); - border-radius: 12px; - background: var(--surface2); - overflow: hidden; - } - .image-result img { - width: 100%; - aspect-ratio: 4 / 3; - object-fit: cover; - display: block; - } - .image-result div { - padding: 9px; + .upload-bg-group.visible { display: flex; } + .upload-bg-name { font-size: 11px; - color: var(--text-2); + color: var(--green); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; } .preview-placeholder { @@ -569,10 +401,62 @@ background: var(--surface2); box-shadow: 0 8px 40px rgba(0,0,0,0.5); display: none; + cursor: grab; + touch-action: none; } .preview-frame.visible { display: block; } + .preview-frame.dragging { cursor: grabbing; } .preview-frame img { width: 100%; height: 100%; object-fit: cover; display: block; } + .thumb-preview-tools { + position: absolute; + top: 10px; + left: 10px; + display: flex; + align-items: center; + gap: 10px; + z-index: 2; + padding: 6px 9px; + border-radius: 999px; + background: rgba(0,0,0,0.58); + border: 1px solid rgba(255,255,255,0.1); + backdrop-filter: blur(8px); + } + .thumb-preview-meta { + color: rgba(255,255,255,0.88); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + white-space: nowrap; + } + .thumb-preview-zoom { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 8px; + z-index: 2; + } + .thumb-preview-hint { + position: absolute; + left: 50%; + bottom: 10px; + transform: translateX(-50%); + padding: 7px 10px; + border-radius: 999px; + background: rgba(0,0,0,0.58); + border: 1px solid rgba(255,255,255,0.1); + color: rgba(255,255,255,0.88); + font-size: 11px; + font-weight: 600; + pointer-events: none; + backdrop-filter: blur(8px); + white-space: nowrap; + z-index: 2; + } + .bd-counter + .thumb-preview-hint { + bottom: 38px; + } + /* Backdrop navigation overlaid on the frame */ .bd-nav { position: absolute; inset: 0; @@ -618,6 +502,7 @@ position: absolute; inset: 0; background: rgba(13,15,18,0.8); display: none; align-items: center; justify-content: center; + z-index: 4; } .loading-cover.on { display: flex; } @@ -652,6 +537,12 @@ text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-2); } + .ctrl-hint { + font-size: 10px; + color: var(--text-3); + max-width: 180px; + line-height: 1.35; + } .ctrl-select { padding: 7px 10px; @@ -990,6 +881,7 @@

Emby Thumbnail Generator

@@ -1038,13 +930,22 @@
-
+
+
+ + 100% · X 0 · Y 0 +
+
+ + +
+
Drag to position • Wheel to zoom
@@ -1071,97 +972,6 @@
-
-
-
-
Artwork Editor
-
Select an Emby item to reframe poster, thumb, or backdrop artwork.
-
-
- - -
-
-
-
-

Source Image

-
- - - -
-

Remote imports will appear here after you search and import a full-size image.

-
- -
-

Editor Canvas

-
-
- -
Generate a preview to start editing.
-
-
-
- - -
-
- - -
-
- - -
-
- - - -
-
- -
-

Export

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-

Exports are cached and uploaded to the matching Emby image type.

-
-
-
-
@@ -1218,16 +1028,12 @@ -
- - - + +
+ +
+ + + + 100% +
+
+ +
+ +
+ + 0 +
+
+ +
+ +
+ + 0 +
+
+ +
+ + +
Uses the current settings. Only items that already have an Emby logo will be updated.
+
+
@@ -1341,40 +1182,13 @@ +
- -