diff --git a/app.py b/app.py index 0c1351c..1980f9d 100644 --- a/app.py +++ b/app.py @@ -1,19 +1,30 @@ import asyncio import io +import logging import os import hashlib import base64 +import random import time from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("embytoolkit") + import httpx from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import HTMLResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError +from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError + +Image.MAX_IMAGE_PIXELS = None # Emby backdrops can exceed PIL's default bomb threshold; source is trusted EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096") EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "b9af54b630f6448289ab96422add567a") @@ -23,7 +34,9 @@ IMPORT_CACHE_DIR = CACHE_DIR / "imports" IMPORT_CACHE_DIR.mkdir(exist_ok=True) EMBY_IMAGE_CACHE_DIR = CACHE_DIR / "emby_images" EMBY_IMAGE_CACHE_DIR.mkdir(exist_ok=True) -RENDER_VERSION = "series-banner-v11" +CLEAN_POSTER_CACHE_DIR = CACHE_DIR / "clean_posters" +CLEAN_POSTER_CACHE_DIR.mkdir(exist_ok=True) +RENDER_VERSION = "series-banner-v24-poster-logo" COLLECTION_RENDER_VERSION = "collection-cover-v1" THUMB_WIDTH = 800 @@ -46,21 +59,56 @@ airing_lookup_lock = asyncio.Lock() # ── Studio logo file map ───────────────────────────────────────────────────── STUDIOS_DIR = Path("static/studios") -# Maps studio key → filename (relative to STUDIOS_DIR) -STUDIO_FILES: dict[str, str] = { - "hulu": "hulu.png", - "hbo": "hbo.png", - "disney": "disney.png", + +# Maps typed search aliases → internal studio key +STUDIO_SEARCH_ALIASES: dict[str, str] = { + "netflix": "netflix", + "apple tv": "appletv", + "apple tv+": "appletv", + "appletv": "appletv", + "appletv+": "appletv", + "apple": "appletv", + "paramount": "paramountplus", + "paramount+": "paramountplus", + "cbs": "paramountplus", + "hbo": "hbo", + "hbo max": "hbo", + "max": "hbo", + "disney": "disney", + "disney+": "disney", + "hulu": "hulu", +} + +# Emby studio display names to search for each key (tried in order) +STUDIO_EMBY_NAMES: dict[str, list[str]] = { + "netflix": ["Netflix"], + "appletv": ["Apple TV+", "Apple Studios", "Apple"], + "paramountplus": ["Paramount+", "CBS Studios", "CBS", "Paramount Network"], + "hbo": ["HBO", "HBO Max", "Max"], + "disney": ["Disney+", "Disney"], + "hulu": ["Hulu"], +} +STUDIO_FILES: dict[str, Path] = { + "hulu": STUDIOS_DIR / "hulu.png", + "hbo": STUDIOS_DIR / "hbo.png", + "disney": STUDIOS_DIR / "disney.png", + "appletv": STUDIOS_DIR / "apple-tv.png", + "paramountplus": STUDIOS_DIR / "paramount-plus.png", + "netflix": STUDIOS_DIR / "netflix.png", } +def round_image_corners(img: Image.Image, radius: int) -> Image.Image: + img = img.convert("RGBA") + mask = Image.new("L", img.size, 0) + ImageDraw.Draw(mask).rounded_rectangle([(0, 0), (img.width - 1, img.height - 1)], radius=radius, fill=255) + img.putalpha(ImageChops.multiply(img.getchannel("A"), mask)) + return img + + def make_studio_logo(studio: str, max_height: int = 52) -> Image.Image | None: - """Load a studio logo from disk and scale it to max_height, preserving aspect ratio.""" - filename = STUDIO_FILES.get(studio) - if not filename: - return None - path = STUDIOS_DIR / filename - if not path.exists(): + path = STUDIO_FILES.get(studio) + if not path or not path.exists(): return None img = Image.open(path).convert("RGBA") if img.height > max_height: @@ -69,6 +117,80 @@ def make_studio_logo(studio: str, max_height: int = 52) -> Image.Image | None: return img +def infer_auto_studio_key(item: dict) -> str | None: + item_type = (item.get("Type") or "").strip().lower() + if item_type not in ("series", "movie"): + return None + image_tags = item.get("ImageTags") or {} + if "Logo" not in image_tags: + return None + + studios = item.get("Studios") or [] + names: list[str] = [] + if isinstance(studios, list): + for studio in studios: + if isinstance(studio, dict): + name = studio.get("Name") or "" + else: + name = str(studio or "") + if name: + names.append(name) + elif isinstance(studios, str) and studios.strip(): + names.append(studios) + + normalized = " ".join(names).lower() + logger.info(" Studio detection : type=%s studios=%s", item_type, names) + + if "apple" in normalized: + return "appletv" + if "netflix" in normalized: + return "netflix" + if "paramount" in normalized: + return "paramountplus" + return None + + +async def get_item_summary(item_id: str) -> dict | None: + data = await emby_get("/Items", { + "Ids": item_id, + "Recursive": "true", + "Fields": "ProductionYear,Studios", + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + }) + items = data.get("Items") or [] + return items[0] if items else None + + +async def apply_auto_studio_defaults(options: dict) -> dict: + effective = dict(options) + requested_studio = (effective.get("studio") or "none").strip().lower() + if requested_studio == "appletv": + effective["studio_position"] = "top-left" + return effective + if requested_studio not in ("auto", ""): + return effective + + item = await get_item_summary(effective["item_id"]) + if not item: + effective["studio"] = "none" + return effective + auto_studio = infer_auto_studio_key(item) + if auto_studio == "appletv": + effective["studio"] = "appletv" + effective["studio_position"] = "top-left" + elif auto_studio == "netflix": + effective["studio"] = "netflix" + effective["studio_position"] = "top-left" + elif auto_studio == "paramountplus": + effective["studio"] = "paramountplus" + effective["studio_position"] = "top-left" + else: + effective["studio"] = "none" + logger.info(" Auto studio : %s → %s", item.get("Name"), effective["studio"]) + return effective + + def load_image_from_bytes(image_bytes: bytes, mode: str = "RGB") -> Image.Image: """Fully decode image bytes into an in-memory PIL image. @@ -180,7 +302,7 @@ async def lifespan(app: FastAPI): http_client = None -app = FastAPI(title="Emby Thumbnail Generator", lifespan=lifespan) +app = FastAPI(title="EmbyToolkit", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") @@ -320,6 +442,14 @@ async def emby_upload_image(item_id: str, image_bytes: bytes, image_type: str = return response.status_code +async def emby_delete_image(item_id: str, image_type: str) -> int: + response = await emby_request( + "DELETE", + f"/Items/{item_id}/Images/{image_type}", + params={"api_key": EMBY_API_KEY}, + headers={"X-Emby-Token": EMBY_API_KEY}, + ) + return response.status_code @@ -340,6 +470,28 @@ def to_emby_iso(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") +async def emby_refresh_backdrops(item_id: str, *, wait_seconds: float = 6.0) -> int: + """Ask Emby to fetch additional backdrop images for an item, then return the new backdrop count.""" + resp = await emby_request("POST", f"/Items/{item_id}/Refresh", params={ + "MetadataRefreshMode": "None", + "ImageRefreshMode": "FullRefresh", + "ReplaceAllImages": "false", + "ReplaceAllMetadata": "false", + }) + if resp.status_code not in (200, 204): + logger.warning("Emby refresh returned %d for item %s", resp.status_code, item_id) + return 0 + await asyncio.sleep(wait_seconds) + data = await emby_get("/Items", { + "Ids": item_id, + "Fields": "", + "ImageTypeLimit": "1", + "EnableImageTypes": "Backdrop", + }) + backdrop_tags = (data.get("Items") or [{}])[0].get("BackdropImageTags") or [] + return len(backdrop_tags) + + async def emby_get_all(path: str, params: dict | None = None, *, page_size: int = 200) -> list[dict]: items: list[dict] = [] start_index = 0 @@ -381,7 +533,7 @@ async def build_airing_snapshot(week_offset: int = 0) -> dict: "SortBy": "SortName", "SortOrder": "Ascending", "ImageTypeLimit": "1", - "EnableImageTypes": "Primary", + "EnableImageTypes": "Primary,Logo", }) recent_episodes_task = emby_get_all("/Items", { "IncludeItemTypes": "Episode", @@ -562,6 +714,7 @@ async def build_airing_snapshot(week_offset: int = 0) -> dict: "status": status or ("Continuing" if upcoming else "Unknown"), "air_days": air_days, "poster_url": f"/api/poster/{series_id}?w=180&h=270&q=84", + "has_logo": "Logo" in (series.get("ImageTags") or {}), "selected_week_air_at": to_emby_iso(selected_week_episode["premiere_at"]) if selected_week_episode else None, "selected_week_episode_label": ( f"S{selected_week_episode['season_number']}E{selected_week_episode['episode_number']} · {selected_week_episode['episode_name']}".strip(" ·") @@ -617,6 +770,235 @@ async def get_airing_snapshot(force_refresh: bool = False, week_offset: int = 0) return data +def can_apply_new_season_snapshot_item(item: dict) -> bool: + return bool(item.get("eligible_new_season")) or ( + (item.get("status") or "").strip().lower() == "continuing" + and (item.get("selected_week_air_at") or item.get("next_air_at")) + ) + + +async def apply_new_season_artwork_for_snapshot_item( + item: dict, + *, + title: str | None = None, + generate_primary: bool = True, +) -> dict: + item_id = item["id"] + if not can_apply_new_season_snapshot_item(item): + raise HTTPException(status_code=400, detail="Series is not eligible for New Season artwork.") + if not item.get("has_logo"): + raise HTTPException(status_code=400, detail="Series requires an Emby logo before New Season artwork can be applied.") + + title = title or item["name"] + item_summary = await get_item_summary(item_id) + auto_studio = infer_auto_studio_key(item_summary or {}) + effective_studio = auto_studio or "none" + effective_studio_position = "top-left" + + cache_key = build_cache_key( + item_id=item_id, + bg_mode="backdrop", + backdrop_index=0, + text_color="#FFFFFF", + logo_align="bottom-center", + logo_scale=1.3, + darkness=0.0, + studio=effective_studio, + studio_position=effective_studio_position, + new_episodes_tag=True, + season_finale_tag=False, + logo_index=0, + ) + thumb_cache_path = get_thumb_cache_path(cache_key) + primary_cache_path = get_primary_cache_path(cache_key) + + if not thumb_cache_path.exists() or (generate_primary and not primary_cache_path.exists()): + poster_bytes = await emby_get_image(item_id, "Primary") + logo_bytes = await emby_get_image_optional(item_id, "Logo", 0) + if logo_bytes is None: + raise HTTPException(status_code=400, detail="Series requires an Emby logo before New Season artwork can be applied.") + backdrop_bytes = await emby_get_image_optional(item_id, "Backdrop", 0) + thumb_bytes = generate_thumbnail( + poster_bytes, + title, + bg_mode="backdrop", + text_color="#FFFFFF", + logo_align="bottom-center", + logo_scale=1.3, + darkness=0.0, + studio=effective_studio, + studio_position=effective_studio_position, + new_episodes_tag=True, + season_finale_tag=False, + logo_index=0, + logo_bytes=logo_bytes, + backdrop_bytes=backdrop_bytes, + ) + thumb_cache_path.write_bytes(thumb_bytes) + if generate_primary: + primary_bytes = generate_primary_cover( + poster_bytes, + title, + bg_mode="backdrop", + text_color="#FFFFFF", + logo_align="bottom-center", + logo_scale=1.3, + darkness=0.0, + studio=effective_studio, + studio_position=effective_studio_position, + new_episodes_tag=True, + season_finale_tag=False, + logo_index=0, + logo_bytes=logo_bytes, + ) + primary_cache_path.write_bytes(primary_bytes) + + thumb_status = await emby_upload_image(item_id, thumb_cache_path.read_bytes(), "Thumb") + + primary_status = None + primary_error = None + if generate_primary: + try: + primary_status = await emby_upload_image(item_id, primary_cache_path.read_bytes(), "Primary") + except Exception as exc: + primary_error = str(exc) + + return { + "status": "applied", + "thumb_code": thumb_status, + "primary_code": primary_status, + "primary_attempted": generate_primary, + "primary_error": primary_error, + } + + +def can_bulk_assign_series_item(item: dict) -> bool: + return bool(item.get("has_logo")) and bool(item.get("has_backdrop")) + + +def build_bulk_assign_series_options(item_id: str, title: str) -> dict: + return { + "item_id": item_id, + "title": title, + "bg_mode": "backdrop", + "backdrop_index": 0, + "text_color": "#FFFFFF", + "logo_align": "bottom-center", + "logo_scale": 1.3, + "darkness": 0.0, + "studio": "auto", + "studio_position": "top-left", + "new_episodes_tag": False, + "season_finale_tag": False, + "generate_primary": False, + "logo_index": 0, + "primary_zoom": 1.0, + "primary_pan_x": 0.0, + "primary_pan_y": -0.16, + "thumb_zoom": 1.0, + "thumb_pan_x": 0.0, + "thumb_pan_y": 0.0, + "upload_bg_id": None, + } + + +async def stamp_studio_logo_on_poster(item_id: str, studio: str, studio_position: str = "top-left") -> int: + """Fetch the clean original poster, stamp the studio logo, and re-upload.""" + logger.info(" Stamping poster : studio=%s position=%s", studio, studio_position) + clean_path = CLEAN_POSTER_CACHE_DIR / f"{item_id}.img" + if clean_path.exists(): + poster_bytes = clean_path.read_bytes() + logger.info(" Poster source : clean cache hit for %s", item_id) + else: + # Primary may be temporarily absent if Emby is mid-refresh after a reset. + # Retry a few times with a short wait before giving up. + poster_bytes = None + for attempt in range(4): + poster_bytes = await emby_get_image_optional(item_id, "Primary") + if poster_bytes: + break + if attempt < 3: + logger.info(" Poster source : Primary not ready (attempt %d/4), waiting…", attempt + 1) + await asyncio.sleep(5) + if not poster_bytes: + logger.warning(" Poster stamp skipped: no Primary image found in Emby for item %s after retries", item_id) + return 0 + clean_path.write_bytes(poster_bytes) + logger.info(" Poster source : saved clean baseline for %s", item_id) + img = load_image_from_bytes(poster_bytes, mode="RGB") + w, h = img.size + logo_max_h = max(70, int(w * 0.09)) + slogo = make_studio_logo(studio, max_height=logo_max_h) + if not slogo: + logo_path = STUDIO_FILES.get(studio) + logger.warning(" Poster stamp skipped: logo file not found for studio=%s path=%s", studio, logo_path) + return 0 + if studio == "paramountplus": + slogo = round_image_corners(slogo, radius=max(6, slogo.height // 5)) + if studio == "appletv": + slogo = slogo.filter(ImageFilter.UnsharpMask(radius=1.0, percent=180, threshold=2)) + s_pad = max(20, int(w * 0.025)) + sw, sh = slogo.size + positions = { + "top-left": (s_pad, s_pad), + "top-right": (w - sw - s_pad, s_pad), + "bottom-left": (s_pad, h - sh - s_pad), + "bottom-right": (w - sw - s_pad, h - sh - s_pad), + } + sx, sy = positions.get(studio_position, positions["top-left"]) + if studio == "appletv": + glow_pad = 10 + shadow_crop = Image.new("RGBA", (sw + glow_pad * 2, sh + glow_pad * 2), (0, 0, 0, 0)) + black = Image.new("RGBA", slogo.size, (0, 0, 0, 160)) + alpha = slogo.getchannel("A") + shadow_badge = Image.merge("RGBA", (*black.split()[:3], alpha)) + shadow_crop.paste(shadow_badge, (glow_pad + 2, glow_pad + 2), shadow_badge) + shadow_crop = shadow_crop.filter(ImageFilter.GaussianBlur(6)) + img = img.convert("RGBA") + img.alpha_composite(shadow_crop, (sx - glow_pad, sy - glow_pad)) + img = img.convert("RGB") + img.paste(slogo, (sx, sy), slogo) + status = await emby_upload_image(item_id, encode_jpeg_bytes(img), "Primary") + logger.info(" Poster stamped : status=%d", status) + return status + + +async def apply_bulk_assign_series_thumb(item: dict, *, backdrop_index: int = 0) -> dict: + if not can_bulk_assign_series_item(item): + raise HTTPException(status_code=400, detail="Series requires Emby primary, logo, and backdrop images before bulk assign can apply.") + + options = await apply_auto_studio_defaults(build_bulk_assign_series_options(item["id"], item["name"])) + options["backdrop_index"] = backdrop_index + + logger.info(" Rendering thumb : %s (backdrop #%d)", item["name"], backdrop_index) + + # Thumb + cache_key = get_generator_cache_key(options) + thumb_cache_path = get_thumb_cache_path(cache_key) + if not thumb_cache_path.exists(): + thumb_bytes, _ = await render_item_artwork(options, fallback_logo_to_first=True) + thumb_cache_path.write_bytes(thumb_bytes) + thumb_status = await emby_upload_image(item["id"], thumb_cache_path.read_bytes(), "Thumb") + + # Poster — stamp logo on existing primary image + studio = options.get("studio") or "none" + poster_status = 0 + if studio and studio != "none": + try: + poster_status = await stamp_studio_logo_on_poster( + item["id"], studio, options.get("studio_position", "top-left") + ) + except Exception as exc: + logger.error(" Poster stamp failed for %s: %s", item["name"], exc) + + logger.info(" Done : %s (thumb=%d, poster=%d)", item["name"], thumb_status, poster_status) + return { + "status": "applied", + "thumb_code": thumb_status, + "poster_code": poster_status, + } + + # --- Image helpers --- def cover_crop(img: Image.Image, width: int, height: int) -> Image.Image: @@ -787,8 +1169,8 @@ def parse_generator_request(body: dict) -> dict: "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"), + "studio": body["studio"] if "studio" in body else "auto", + "studio_position": body.get("studio_position", "top-left"), "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)), @@ -822,7 +1204,7 @@ async def get_logo_bytes_for_item( 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") + poster_bytes = await get_cached_emby_source_image(options["item_id"], "Primary", optional=True) logo_bytes = await get_logo_bytes_for_item( options["item_id"], options["logo_index"], @@ -923,7 +1305,7 @@ def build_cache_key( logo_scale: float = 1.3, darkness: float = 0.0, studio: str = "none", - studio_position: str = "bottom-right", + studio_position: str = "top-left", new_episodes_tag: bool = False, season_finale_tag: bool = False, logo_index: int | None = None, @@ -977,7 +1359,7 @@ def generate_thumbnail( logo_scale: float = 1.3, darkness: float = 0.0, studio: str = "none", - studio_position: str = "bottom-right", + studio_position: str = "top-left", new_episodes_tag: bool = False, season_finale_tag: bool = False, logo_index: int | None = None, @@ -1016,22 +1398,28 @@ def generate_thumbnail( 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)) else: - poster = load_image_from_bytes(poster_bytes, mode="RGB") - if is_tall_layout: - bg = build_tall_backdrop_background( - poster, - width, - height, - dim_factor=bg_dim, - zoom=primary_zoom, - pan_x=primary_pan_x, - pan_y=primary_pan_y, - ) + if not poster_bytes: + bg = Image.new("RGB", (width, height), (20, 20, 28)) + bg = bg.point(lambda p: int(p * bg_dim)) + poster = None else: - # Blurred poster fallback for landscape thumbs. - 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)) + poster = load_image_from_bytes(poster_bytes, mode="RGB") + if poster is not None: + if is_tall_layout: + bg = build_tall_backdrop_background( + poster, + width, + height, + dim_factor=bg_dim, + zoom=primary_zoom, + pan_x=primary_pan_x, + pan_y=primary_pan_y, + ) + else: + # Blurred poster fallback for landscape thumbs. + 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)) # --- Vignette overlay tuned per logo position --- overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0)) @@ -1179,9 +1567,16 @@ def generate_thumbnail( # --- Studio logo overlay --- if studio and studio != "none": - slogo = make_studio_logo(studio, max_height=46) + effective_studio_position = studio_position + studio_max_height = 63 if studio == "appletv" else 75 if studio == "paramountplus" else 60 if studio == "netflix" else 65 + slogo = make_studio_logo(studio, max_height=studio_max_height) if slogo: - s_pad = 22 + if studio == "paramountplus": + corner_r = max(6, slogo.height // 5) + slogo = round_image_corners(slogo, radius=corner_r) + if studio == "appletv": + slogo = slogo.filter(ImageFilter.UnsharpMask(radius=1.0, percent=180, threshold=2)) + s_pad = 18 if studio == "appletv" else 22 sw, sh = slogo.size top_y = s_pad if not banner else banner["y"] + banner["h"] + 10 positions = { @@ -1190,7 +1585,19 @@ def generate_thumbnail( "bottom-left": (s_pad, height - sh - s_pad), "bottom-right": (width - sw - s_pad, height - sh - s_pad), } - sx, sy = positions.get(studio_position, positions["bottom-right"]) + sx, sy = positions.get(effective_studio_position, positions["bottom-right"]) + if studio == "appletv": + # Soft drop-shadow so the wordmark reads cleanly on any backdrop. + glow_pad = 8 + shadow_crop = Image.new("RGBA", (sw + glow_pad * 2, sh + glow_pad * 2), (0, 0, 0, 0)) + black = Image.new("RGBA", slogo.size, (0, 0, 0, 160)) + alpha = slogo.getchannel("A") + shadow_badge = Image.merge("RGBA", (*black.split()[:3], alpha)) + shadow_crop.paste(shadow_badge, (glow_pad + 2, glow_pad + 2), shadow_badge) + shadow_crop = shadow_crop.filter(ImageFilter.GaussianBlur(5)) + bg = bg.convert("RGBA") + bg.alpha_composite(shadow_crop, (sx - glow_pad, sy - glow_pad)) + bg = bg.convert("RGB") bg.paste(slogo, (sx, sy), slogo) studio_box = (sx, sy, sw, sh) @@ -1217,7 +1624,7 @@ def generate_primary_cover( logo_scale: float = 1.3, darkness: float = 0.0, studio: str = "none", - studio_position: str = "bottom-right", + studio_position: str = "top-left", new_episodes_tag: bool = False, season_finale_tag: bool = False, logo_index: int | None = None, @@ -1591,6 +1998,111 @@ async def airing_page(request: Request): return templates.TemplateResponse(request, "airing.html") +@app.get("/bulk-assign", response_class=HTMLResponse) +async def bulk_assign_page(request: Request): + return templates.TemplateResponse(request, "bulk_assign.html") + + +@app.get("/api/bulk-assign/series") +async def get_bulk_assign_series( + q: str = Query(""), + start: int = Query(0, ge=0), + limit: int = Query(24, ge=1, le=60), +): + params = { + "IncludeItemTypes": "Series", + "Recursive": "true", + "SortBy": "SortName", + "SortOrder": "Ascending", + "Fields": "ProductionYear", + "StartIndex": str(start), + "Limit": str(limit), + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + } + if q.strip(): + params["SearchTerm"] = q.strip() + + data = await emby_get("/Items", params) + items: list[dict] = [] + for item in data.get("Items", []): + image_tags = item.get("ImageTags") or {} + backdrop_tags = item.get("BackdropImageTags") or [] + entry = { + "id": item["Id"], + "name": item.get("Name", ""), + "year": item.get("ProductionYear"), + "type": item.get("Type", ""), + "poster_url": f"/api/poster/{item['Id']}?w=180&h=270&q=84" if "Primary" in image_tags else None, + "has_primary": "Primary" in image_tags, + "has_logo": "Logo" in image_tags, + "has_backdrop": bool(backdrop_tags), + "backdrop_count": len(backdrop_tags), + } + entry["can_bulk_assign"] = can_bulk_assign_series_item(entry) + items.append(entry) + + total = int(data.get("TotalRecordCount", start + len(items))) + return { + "items": items, + "start": start, + "limit": limit, + "total": total, + "has_more": start + len(items) < total, + "query": q.strip(), + } + + +@app.get("/api/bulk-assign/movies") +async def get_bulk_assign_movies( + q: str = Query(""), + start: int = Query(0, ge=0), + limit: int = Query(24, ge=1, le=60), +): + params = { + "IncludeItemTypes": "Movie", + "Recursive": "true", + "SortBy": "SortName", + "SortOrder": "Ascending", + "Fields": "ProductionYear", + "StartIndex": str(start), + "Limit": str(limit), + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + } + if q.strip(): + params["SearchTerm"] = q.strip() + + data = await emby_get("/Items", params) + items: list[dict] = [] + for item in data.get("Items", []): + image_tags = item.get("ImageTags") or {} + backdrop_tags = item.get("BackdropImageTags") or [] + entry = { + "id": item["Id"], + "name": item.get("Name", ""), + "year": item.get("ProductionYear"), + "type": item.get("Type", ""), + "poster_url": f"/api/poster/{item['Id']}?w=180&h=270&q=84" if "Primary" in image_tags else None, + "has_primary": "Primary" in image_tags, + "has_logo": "Logo" in image_tags, + "has_backdrop": bool(backdrop_tags), + "backdrop_count": len(backdrop_tags), + } + entry["can_bulk_assign"] = can_bulk_assign_series_item(entry) + items.append(entry) + + total = int(data.get("TotalRecordCount", start + len(items))) + return { + "items": items, + "start": start, + "limit": limit, + "total": total, + "has_more": start + len(items) < total, + "query": q.strip(), + } + + @app.get("/api/airing") async def get_airing_titles( page: int = Query(1, ge=1), @@ -1631,11 +2143,7 @@ async def apply_new_season_banner(request: Request): item = next((entry for entry in snapshot["items"] if entry["id"] == item_id), None) if item is None: raise HTTPException(status_code=404, detail="Series was not found in the current airing snapshot.") - can_apply_new_season = bool(item["eligible_new_season"]) or ( - (item.get("status") or "").strip().lower() == "continuing" - and (item.get("selected_week_air_at") or item.get("next_air_at")) - ) - if not can_apply_new_season: + if not can_apply_new_season_snapshot_item(item): raise HTTPException( status_code=400, detail=( @@ -1644,100 +2152,339 @@ async def apply_new_season_banner(request: Request): f"from the season premiere." ), ) - - title = body.get("title") or item["name"] - cache_key = build_cache_key( - item_id=item_id, - bg_mode="backdrop", - backdrop_index=0, - text_color="#FFFFFF", - logo_align="bottom-center", - logo_scale=1.3, - darkness=0.0, - studio="none", - studio_position="bottom-right", - new_episodes_tag=True, - season_finale_tag=False, - logo_index=0, + return await apply_new_season_artwork_for_snapshot_item( + item, + title=body.get("title") or item["name"], + generate_primary=generate_primary, ) - thumb_cache_path = get_thumb_cache_path(cache_key) - primary_cache_path = get_primary_cache_path(cache_key) - if not thumb_cache_path.exists() or (generate_primary and not primary_cache_path.exists()): - poster_bytes = await emby_get_image(item_id, "Primary") - logo_bytes = await emby_get_image_optional(item_id, "Logo", 0) - backdrop_bytes = await emby_get_image_optional(item_id, "Backdrop", 0) - thumb_bytes = generate_thumbnail( - poster_bytes, - title, - bg_mode="backdrop", - text_color="#FFFFFF", - logo_align="bottom-center", - logo_scale=1.3, - darkness=0.0, - studio="none", - studio_position="bottom-right", - new_episodes_tag=True, - season_finale_tag=False, - logo_index=0, - logo_bytes=logo_bytes, - backdrop_bytes=backdrop_bytes, - ) - thumb_cache_path.write_bytes(thumb_bytes) - if generate_primary: - primary_bytes = generate_primary_cover( - poster_bytes, - title, - bg_mode="backdrop", - text_color="#FFFFFF", - logo_align="bottom-center", - logo_scale=1.3, - darkness=0.0, - studio="none", - studio_position="bottom-right", - new_episodes_tag=True, - season_finale_tag=False, - logo_index=0, - logo_bytes=logo_bytes, - ) - primary_cache_path.write_bytes(primary_bytes) - thumb_status = await emby_upload_image(item_id, thumb_cache_path.read_bytes(), "Thumb") +@app.post("/api/bulk-assign/apply") +@app.post("/api/airing/apply-new-season/bulk") +async def bulk_apply_new_season_banner(request: Request): + body = await request.json() + item_ids_raw = body.get("item_ids") or [] + if not isinstance(item_ids_raw, list): + raise HTTPException(status_code=400, detail="item_ids must be a list.") - primary_status = None - primary_error = None + item_ids: list[str] = [] + seen_item_ids: set[str] = set() + for raw_item_id in item_ids_raw: + item_id = str(raw_item_id or "").strip() + if not item_id or item_id in seen_item_ids: + continue + seen_item_ids.add(item_id) + item_ids.append(item_id) + if not item_ids: + raise HTTPException(status_code=400, detail="At least one series must be selected.") + + generate_primary = bool(body.get("generate_primary", False)) if generate_primary: + raise HTTPException(status_code=400, detail="Bulk assign only applies thumb artwork.") + + items = await emby_get_all("/Items", { + "Ids": ",".join(item_ids), + "IncludeItemTypes": "Series,Movie", + "Recursive": "true", + "Fields": "ProductionYear", + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + }) + items_by_id = {} + for entry in items: + image_tags = entry.get("ImageTags") or {} + backdrop_tags = entry.get("BackdropImageTags") or [] + item = { + "id": entry.get("Id"), + "name": entry.get("Name", ""), + "has_primary": "Primary" in image_tags, + "has_logo": "Logo" in image_tags, + "has_backdrop": bool(backdrop_tags), + } + if item["id"]: + items_by_id[item["id"]] = item + + applied: list[str] = [] + skipped_not_found: list[str] = [] + skipped_missing_assets: list[str] = [] + failed: list[dict] = [] + + logger.info("=== Bulk Apply Selected: %d %s item(s) ===", len(item_ids), emby_type) + for i, item_id in enumerate(item_ids): + item = items_by_id.get(item_id) + if item is None: + logger.warning("[%d/%d] ID %s — not found in Emby", i + 1, len(item_ids), item_id) + skipped_not_found.append(item_id) + continue + if not can_bulk_assign_series_item(item): + logger.info("[%d/%d] %s — skipped (missing logo or backdrop)", i + 1, len(item_ids), item["name"]) + skipped_missing_assets.append(item.get("name") or item_id) + continue + logger.info("[%d/%d] %s", i + 1, len(item_ids), item["name"]) try: - primary_status = await emby_upload_image(item_id, primary_cache_path.read_bytes(), "Primary") + await apply_bulk_assign_series_thumb(item) + applied.append(item.get("name") or item_id) except Exception as exc: - primary_error = str(exc) + err_msg = exc.detail if isinstance(exc, HTTPException) else str(exc) + logger.error(" FAILED: %s — %s", item["name"], err_msg) + failed.append({"name": item.get("name") or item_id, "error": err_msg}) + + logger.info( + "=== Bulk Apply Selected complete: %d applied, %d skipped, %d not found, %d failed ===", + len(applied), len(skipped_missing_assets), len(skipped_not_found), len(failed), + ) + return { + "status": "completed", + "requested_count": len(item_ids), + "applied_count": len(applied), + "skipped_missing_assets_count": len(skipped_missing_assets), + "skipped_not_found_count": len(skipped_not_found), + "failed_count": len(failed), + "applied": applied[:12], + "failed": failed[:12], + } + + +@app.post("/api/bulk-assign/apply-all") +async def bulk_apply_all_eligible(request: Request): + body = await request.json() + item_type = str(body.get("item_type") or "series").strip().lower() + if item_type not in ("series", "movie"): + raise HTTPException(status_code=400, detail="item_type must be 'series' or 'movie'.") + emby_type = "Series" if item_type == "series" else "Movie" + + logger.info("=== Bulk Apply All: fetching all %s items from Emby ===", emby_type) + all_entries = await emby_get_all("/Items", { + "IncludeItemTypes": emby_type, + "Recursive": "true", + "Fields": "ProductionYear", + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + }) + logger.info("Fetched %d total %s entries from Emby", len(all_entries), emby_type) + + eligible = [] + skipped_missing = 0 + for entry in all_entries: + image_tags = entry.get("ImageTags") or {} + backdrop_tags = entry.get("BackdropImageTags") or [] + item = { + "id": entry.get("Id"), + "name": entry.get("Name", ""), + "has_primary": "Primary" in image_tags, + "has_logo": "Logo" in image_tags, + "has_backdrop": bool(backdrop_tags), + "backdrop_count": len(backdrop_tags), + } + if item["id"] and can_bulk_assign_series_item(item): + eligible.append(item) + else: + skipped_missing += 1 + + logger.info( + "%d eligible (logo+backdrop present), %d skipped (missing assets)", + len(eligible), skipped_missing, + ) + + applied: list[str] = [] + failed: list[dict] = [] + + for i, item in enumerate(eligible): + position = i + 1 + # Every 5th item: try to use a second backdrop for variety + if position % 5 == 0: + if item["backdrop_count"] < 2: + logger.info( + "[%d/%d] %s — every-5th variant, only 1 backdrop; requesting more from Emby…", + position, len(eligible), item["name"], + ) + new_count = await emby_refresh_backdrops(item["id"]) + item["backdrop_count"] = new_count + logger.info( + " Emby returned %d backdrop(s) after refresh", new_count, + ) + if item["backdrop_count"] >= 2: + backdrop_index = random.randint(0, 1) + logger.info( + "[%d/%d] %s — every-5th variant, using random backdrop #%d", + position, len(eligible), item["name"], backdrop_index, + ) + else: + backdrop_index = 0 + logger.info( + "[%d/%d] %s — every-5th variant, only 1 backdrop available, using #0", + position, len(eligible), item["name"], + ) + else: + backdrop_index = 0 + logger.info("[%d/%d] %s", position, len(eligible), item["name"]) + + try: + await apply_bulk_assign_series_thumb(item, backdrop_index=backdrop_index) + applied.append(item.get("name") or item["id"]) + except Exception as exc: + err_msg = exc.detail if isinstance(exc, HTTPException) else str(exc) + logger.error(" FAILED: %s — %s", item["name"], err_msg) + failed.append({"name": item.get("name") or item["id"], "error": err_msg}) + + logger.info( + "=== Bulk Apply All complete: %d applied, %d failed ===", + len(applied), len(failed), + ) + if failed: + for f in failed: + logger.warning(" Failed: %s — %s", f["name"], f["error"]) return { - "status": "applied", - "thumb_code": thumb_status, - "primary_code": primary_status, - "primary_attempted": options["generate_primary"], - "primary_error": primary_error, + "status": "completed", + "eligible_count": len(eligible), + "applied_count": len(applied), + "failed_count": len(failed), + "applied": applied[:20], + "failed": failed[:20], } +@app.post("/api/bulk-reset/studio") +async def bulk_reset_studio(request: Request): + body = await request.json() + studio_key = (body.get("studio_key") or "").strip().lower() + if studio_key not in STUDIO_EMBY_NAMES: + raise HTTPException(status_code=400, detail=f"Unknown studio key: {studio_key}") + + studio_ids = await find_studio_ids(studio_key) + if not studio_ids: + return {"reset": 0, "skipped": 0, "message": "No matching studio found in Emby."} + + items = await emby_get_all("/Items", { + "StudioIds": ",".join(studio_ids), + "IncludeItemTypes": "Series,Movie", + "Recursive": "true", + "Fields": "", + "ImageTypeLimit": "0", + }) + + reset = 0 + skipped = 0 + for entry in items: + item_id = entry.get("Id") + if not item_id: + continue + try: + await emby_delete_image(item_id, "Thumb") + await emby_delete_image(item_id, "Primary") + # Invalidate local image caches so the next fetch gets the fresh Emby version + for image_type in ("Primary", "Thumb"): + cache_path = get_emby_image_cache_path(item_id, image_type) + cache_path.unlink(missing_ok=True) + get_emby_image_miss_path(item_id, "Primary").unlink(missing_ok=True) + (CLEAN_POSTER_CACHE_DIR / f"{item_id}.img").unlink(missing_ok=True) + # Ask Emby to fill in the now-missing Primary from its image providers (e.g. TMDB). + # ReplaceAllImages=false is intentional: Emby will only download images that are + # absent, so once we upload our stamped poster it won't be overwritten by this job. + resp = await emby_request( + "POST", + f"/Items/{item_id}/Refresh", + params={ + "MetadataRefreshMode": "None", + "ImageRefreshMode": "FullRefresh", + "ReplaceAllImages": "false", + "ReplaceAllMetadata": "false", + }, + ) + if resp.status_code not in (200, 204): + logger.warning(" Emby refresh returned %d for item %s", resp.status_code, item_id) + reset += 1 + except Exception as exc: + logger.error(" Reset failed for item %s: %s", item_id, exc) + skipped += 1 + + if reset: + # Give Emby a head start re-downloading images before the client re-applies. + await asyncio.sleep(5) + + return {"reset": reset, "skipped": skipped} + + +@app.post("/api/clean-poster-cache/clear") +async def clear_clean_poster_cache(request: Request): + """Delete clean poster baselines for given item IDs (or all if none supplied). + + Call this after restoring original posters in Emby so the next bulk assign + picks up the fresh clean image rather than a previously-stamped one. + """ + body = await request.json() + item_ids = body.get("item_ids") or [] + if item_ids: + cleared = 0 + for item_id in item_ids: + path = CLEAN_POSTER_CACHE_DIR / f"{str(item_id).strip()}.img" + if path.exists(): + path.unlink() + cleared += 1 + return {"cleared": cleared, "scope": "selected"} + else: + files = list(CLEAN_POSTER_CACHE_DIR.glob("*.img")) + for f in files: + f.unlink(missing_ok=True) + return {"cleared": len(files), "scope": "all"} + + +async def find_studio_ids(studio_key: str) -> list[str]: + """Return Emby studio IDs for a given studio key by searching known display names.""" + ids: list[str] = [] + seen: set[str] = set() + for name in STUDIO_EMBY_NAMES.get(studio_key, []): + data = await emby_get("/Studios", {"SearchTerm": name, "Limit": "10"}) + for item in data.get("Items", []): + sid = item.get("Id") + sname = (item.get("Name") or "").lower() + if sid and sid not in seen and (name.lower() in sname or sname in name.lower()): + ids.append(sid) + seen.add(sid) + return ids + + @app.get("/api/search") async def search_items( q: str = Query(..., min_length=1), type: str = "Movie,Series", start: int = Query(0, ge=0), - limit: int = Query(12, ge=1, le=24), + limit: int = Query(12, ge=1, le=500), ): - data = await emby_get("/Items", { - "SearchTerm": q, - "IncludeItemTypes": type, - "Recursive": "true", - "Fields": "ProductionYear", - "StartIndex": str(start), - "Limit": str(limit), - "ImageTypeLimit": "1", - "EnableImageTypes": "Primary,Logo,Backdrop", - }) + studio_key = STUDIO_SEARCH_ALIASES.get(q.strip().lower()) + if studio_key: + studio_ids = await find_studio_ids(studio_key) + if studio_ids: + base_params = { + "StudioIds": ",".join(studio_ids), + "IncludeItemTypes": type, + "Recursive": "true", + "Fields": "ProductionYear,Studios", + "SortBy": "SortName", + "SortOrder": "Ascending", + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + } + if limit >= 100: + # Bulk fetch — paginate through everything Emby has + all_items = await emby_get_all("/Items", base_params) + data = {"Items": all_items, "TotalRecordCount": len(all_items)} + else: + data = await emby_get("/Items", {**base_params, "StartIndex": str(start), "Limit": str(limit)}) + else: + data = {"Items": [], "TotalRecordCount": 0} + else: + data = await emby_get("/Items", { + "SearchTerm": q, + "IncludeItemTypes": type, + "Recursive": "true", + "Fields": "ProductionYear,Studios", + "StartIndex": str(start), + "Limit": str(limit), + "ImageTypeLimit": "1", + "EnableImageTypes": "Primary,Logo,Backdrop", + }) items = [] for item in data.get("Items", []): image_tags = item.get("ImageTags", {}) @@ -1748,6 +2495,7 @@ async def search_items( "year": item.get("ProductionYear", ""), "type": item.get("Type", ""), "has_logo": "Logo" in image_tags, + "auto_studio": infer_auto_studio_key(item), "backdrop_count": len(backdrop_count), "poster_url": f"/api/poster/{item['Id']}?w=72&h=108&q=72", }) @@ -1758,6 +2506,7 @@ async def search_items( "limit": limit, "total": total, "has_more": start + len(items) < total, + "studio_key": studio_key, } @@ -1996,35 +2745,31 @@ async def apply_collection_art(request: Request): @app.post("/api/generate") async def generate(request: Request): - options = parse_generator_request(await request.json()) + options = await apply_auto_studio_defaults(parse_generator_request(await request.json())) + render_options = dict(options) + render_options["generate_primary"] = False 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): + if thumb_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-Primary-Generated": "0", "X-Cache-Key": cache_key, }, ) - thumb_bytes, primary_bytes = await render_item_artwork(options) + thumb_bytes, _ = await render_item_artwork(render_options) thumb_cache_path.write_bytes(thumb_bytes) - 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), media_type="image/png", headers={ - "X-Primary-Generated": "1" if primary_generated else "0", + "X-Primary-Generated": "0", "X-Cache-Key": cache_key, }, ) @@ -2032,16 +2777,15 @@ async def generate(request: Request): @app.post("/api/apply") async def apply_to_emby(request: Request): - options = parse_generator_request(await request.json()) + options = await apply_auto_studio_defaults(parse_generator_request(await request.json())) + render_options = dict(options) + render_options["generate_primary"] = False 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(): - thumb_bytes, primary_bytes = await render_item_artwork(options) + thumb_bytes, _ = await render_item_artwork(render_options) thumb_cache_path.write_bytes(thumb_bytes) - 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(options["item_id"], thumb_bytes, "Thumb") @@ -2050,14 +2794,15 @@ async def apply_to_emby(request: Request): primary_error = None if options["generate_primary"]: try: - if primary_cache_path.exists(): - primary_bytes = primary_cache_path.read_bytes() + studio = (options.get("studio") or "none").strip().lower() + if studio == "none": + primary_error = "Primary poster stamping requires a studio logo selection." else: - _, 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(options["item_id"], primary_bytes, "Primary") + primary_status = await stamp_studio_logo_on_poster( + options["item_id"], + studio, + options.get("studio_position", "top-left"), + ) except Exception as exc: primary_error = str(exc) @@ -2078,7 +2823,7 @@ async def bulk_apply_category(request: Request): if not category_id: raise HTTPException(status_code=400, detail="Category is required.") - base_options = parse_generator_request(body) + base_options = await apply_auto_studio_defaults(parse_generator_request(body)) items = await emby_get_all("/Items", { "GenreIds": category_id, "IncludeItemTypes": "Movie,Series", @@ -2112,6 +2857,7 @@ async def bulk_apply_category(request: Request): if item.get("Type") != "Series": item_options["new_episodes_tag"] = False item_options["season_finale_tag"] = False + item_options = await apply_auto_studio_defaults(item_options) 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) @@ -2128,7 +2874,6 @@ async def bulk_apply_category(request: Request): "name": item.get("Name", ""), "error": str(exc), }) - return { "status": "completed", "category_id": category_id, diff --git a/atv.png b/atv.png new file mode 100644 index 0000000..4e52aeb Binary files /dev/null and b/atv.png differ diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/output/appletv-test-2.png b/output/appletv-test-2.png new file mode 100644 index 0000000..dddd862 Binary files /dev/null and b/output/appletv-test-2.png differ diff --git a/output/appletv-test-local.png b/output/appletv-test-local.png new file mode 100644 index 0000000..c13b996 Binary files /dev/null and b/output/appletv-test-local.png differ diff --git a/output/appletv-test.png b/output/appletv-test.png new file mode 100644 index 0000000..dddd862 Binary files /dev/null and b/output/appletv-test.png differ diff --git a/paramount.png b/paramount.png new file mode 100644 index 0000000..1aeccd0 Binary files /dev/null and b/paramount.png differ diff --git a/static/studios/apple-tv.png b/static/studios/apple-tv.png new file mode 100644 index 0000000..4e52aeb Binary files /dev/null and b/static/studios/apple-tv.png differ diff --git a/static/studios/netflix.png b/static/studios/netflix.png new file mode 100644 index 0000000..3ce65a3 Binary files /dev/null and b/static/studios/netflix.png differ diff --git a/static/studios/paramount-plus.png b/static/studios/paramount-plus.png new file mode 100644 index 0000000..1aeccd0 Binary files /dev/null and b/static/studios/paramount-plus.png differ diff --git a/templates/airing.html b/templates/airing.html index 57c8a93..ef416a6 100644 --- a/templates/airing.html +++ b/templates/airing.html @@ -3,7 +3,7 @@ -Current Airing Shows +EmbyToolkit + + + + + +
+
+
+

Bulk Assign

+

+ Bulk assign applies generated Thumb artwork only, + and is enabled when Emby already has a Logo and Backdrop. +

+
+
+ +
+ + +
+ +
+
+
0 series
+
Search All
+
+
+ + + + + + +
0 selected
+
+
+ +
+
Loading series…
+
+ +
+
No results
+
+ + +
+
+
+ +
+ + + + + diff --git a/templates/collections.html b/templates/collections.html index 9252c91..405ed82 100644 --- a/templates/collections.html +++ b/templates/collections.html @@ -3,7 +3,7 @@ -Emby Collection Artwork +EmbyToolkit