From b960815217baf141476c9176140e063992c53be0 Mon Sep 17 00:00:00 2001 From: CHAFFY Date: Sat, 14 Mar 2026 03:44:20 +0200 Subject: [PATCH 1/3] feat: add translated logo with tmdb --- app/api/endpoints/meta.py | 38 ++++++++- .../recommendation/catalog_service.py | 6 +- app/services/recommendation/metadata.py | 34 +++++++- app/services/tmdb/service.py | 85 +++++++++++++++++++ 4 files changed, 156 insertions(+), 7 deletions(-) diff --git a/app/api/endpoints/meta.py b/app/api/endpoints/meta.py index db2367d..8e35222 100644 --- a/app/api/endpoints/meta.py +++ b/app/api/endpoints/meta.py @@ -1,6 +1,6 @@ import asyncio -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from loguru import logger from app.services.tmdb.service import get_tmdb_service @@ -55,3 +55,39 @@ async def get_languages(): except Exception as e: logger.error(f"Failed to fetch languages: {e}") raise HTTPException(status_code=502, detail="Failed to fetch languages from TMDB") + + +@router.get("/api/meta/images") +async def get_meta_images( + imdb_id: str | None = Query(None, description="IMDb ID (e.g. tt1234567)"), + tmdb_id: int | None = Query(None, description="TMDB ID (use with kind)"), + kind: str = Query("movie", description="Type: movie or series"), + language: str = Query("en-US", description="Language for image preference (e.g. en-US, fr-FR)"), +): + """ + Return logo, poster and background in the requested language. + Provide either imdb_id (and optionally kind) or tmdb_id + kind. + """ + try: + tmdb = get_tmdb_service(language=language) + media_type = "tv" if kind == "series" else "movie" + + if imdb_id: + clean_imdb = imdb_id.strip().lower() + if not clean_imdb.startswith("tt"): + clean_imdb = "tt" + clean_imdb + tid, found_type = await tmdb.find_by_imdb_id(clean_imdb) + if tid is None: + raise HTTPException(status_code=404, detail="Title not found on TMDB") + media_type = found_type + tmdb_id = tid + elif tmdb_id is None: + raise HTTPException(status_code=400, detail="Provide imdb_id or tmdb_id") + + images = await tmdb.get_images_for_title(media_type, tmdb_id, language=language) + return images + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to fetch meta images: {e}") + raise HTTPException(status_code=502, detail="Failed to fetch images from TMDB") diff --git a/app/services/recommendation/catalog_service.py b/app/services/recommendation/catalog_service.py index 089c005..e277765 100644 --- a/app/services/recommendation/catalog_service.py +++ b/app/services/recommendation/catalog_service.py @@ -49,6 +49,7 @@ def _clean_meta(meta: dict) -> dict | None: "type", "name", "poster", + "logo", "background", "description", "releaseInfo", @@ -73,8 +74,9 @@ def _clean_meta(meta: dict) -> dict | None: # if id does not start with tt, return None if not imdb_id.startswith("tt"): return None - # Add Metahub logo URL (used by Stremio) - cleaned["logo"] = f"https://live.metahub.space/logo/medium/{imdb_id}/img" + # Use Metahub logo only when no language-aware logo was set (e.g. from TMDB) + if not cleaned.get("logo"): + cleaned["logo"] = f"https://live.metahub.space/logo/medium/{imdb_id}/img" return cleaned diff --git a/app/services/recommendation/metadata.py b/app/services/recommendation/metadata.py index 9979186..e96a120 100644 --- a/app/services/recommendation/metadata.py +++ b/app/services/recommendation/metadata.py @@ -28,7 +28,11 @@ def extract_year(item: dict[str, Any]) -> int | None: @classmethod async def format_for_stremio( - cls, details: dict[str, Any], media_type: str, user_settings: Any = None + cls, + details: dict[str, Any], + media_type: str, + user_settings: Any = None, + logo_url: str | None = None, ) -> dict[str, Any] | None: """Format TMDB details into Stremio metadata object.""" external_ids = details.get("external_ids", {}) @@ -69,6 +73,8 @@ async def format_for_stremio( "_tmdb_id": tmdb_id_raw, "genre_ids": [g.get("id") for g in genres_full if isinstance(g, dict) and g.get("id") is not None], } + if logo_url: + meta_data["logo"] = logo_url # Extensions runtime_str = cls._extract_runtime_string(details) @@ -152,9 +158,29 @@ async def _fetch_one(tid: int): tasks = [_fetch_one(it.get("id")) for it in valid_items] details_list = await asyncio.gather(*tasks) - format_task = [ - cls.format_for_stremio(details, media_type, user_settings) for details in details_list if details - ] + language = getattr(user_settings, "language", None) or "en-US" + mt = "movie" if media_type == "movie" else "tv" + + async def _images_one(d: dict[str, Any]) -> dict[str, str]: + async with sem: + try: + return await tmdb_service.get_images_for_title(mt, d["id"], language=language) + except Exception: + return {} + + image_tasks = [_images_one(d) for d in details_list if d] + images_list = await asyncio.gather(*image_tasks, return_exceptions=True) + + format_task = [] + for i, details in enumerate(details_list): + if not details: + continue + logo_url = None + if i < len(images_list): + imgs = images_list[i] + if isinstance(imgs, dict): + logo_url = imgs.get("logo") or None + format_task.append(cls.format_for_stremio(details, media_type, user_settings, logo_url=logo_url)) formatted_list = await asyncio.gather(*format_task, return_exceptions=True) diff --git a/app/services/tmdb/service.py b/app/services/tmdb/service.py index 3157bf6..74cd15e 100644 --- a/app/services/tmdb/service.py +++ b/app/services/tmdb/service.py @@ -138,6 +138,91 @@ async def get_primary_translations(self) -> list[str]: """Fetch supported primary translations from TMDB.""" return await self.client.get("/configuration/primary_translations") + @alru_cache(maxsize=2000, ttl=86400) + async def get_images(self, media_type: str, tmdb_id: int, include_image_language: str = "en,fr,null") -> dict[str, Any]: + """ + Fetch images (posters, logos, backdrops) for a movie or TV show. + include_image_language: comma-separated iso_639_1 codes + "null" for language-less images. + """ + if media_type not in ("movie", "tv"): + return {} + path = f"/{media_type}/{tmdb_id}/images" + params = {"include_image_language": include_image_language} + return await self.client.get(path, params=params) + + @staticmethod + def _pick_image_by_language( + images_list: list[dict[str, Any]] | None, + preferred_lang_codes: list[str | None], + ) -> str | None: + """ + Pick best image from list by language preference (same logic as no-stremio-addon). + preferred_lang_codes: e.g. ["en", None, "fr"] -> prefer en, then no language, then fr. + """ + if not images_list: + return None + for lang in preferred_lang_codes: + for img in images_list: + iso = img.get("iso_639_1") + if (iso or None) == (lang if lang else None): + path = img.get("file_path") + if path: + return path + return images_list[0].get("file_path") if images_list else None + + def _language_to_image_preference(self, language: str) -> tuple[list[str | None], str]: + """ + Build preferred lang order and include_image_language param from language (e.g. en-US, fr-FR). + Returns (preferred_lang_codes, include_image_language). + """ + primary = (language or "en-US").split("-")[0].lower() if language else "en" + fallbacks = [c for c in ("en", "fr", "null") if c != primary] + preferred = [primary, None, *[c for c in fallbacks if c != "null"]] + include = ",".join([primary] + fallbacks) + return preferred, include + + async def get_images_for_title( + self, + media_type: str, + tmdb_id: int, + language: str | None = None, + ) -> dict[str, str]: + """ + Get poster, logo and background URLs for a title in the requested language. + Same approach as no-stremio-addon: request images with include_image_language, + then pick by preferred language (requested lang, then null, then fallbacks). + """ + lang = language or self.client.language + preferred, include = self._language_to_image_preference(lang) + data = await self.get_images(media_type, tmdb_id, include_image_language=include) + if not data: + return {} + + base_poster_logo = "https://image.tmdb.org/t/p/w500" + base_backdrop = "https://image.tmdb.org/t/p/w780" + + def to_url(base: str, path: str | None) -> str: + if not path: + return "" + return base + (path if path.startswith("/") else "/" + path) + + posters = data.get("posters") or [] + logos = data.get("logos") or [] + backdrops = data.get("backdrops") or [] + + poster_path = self._pick_image_by_language(posters, preferred) + logo_path = self._pick_image_by_language(logos, preferred) + backdrop_path = self._pick_image_by_language(backdrops, preferred) + + result: dict[str, str] = {} + if poster_path: + result["poster"] = to_url(base_poster_logo, poster_path) + if logo_path: + result["logo"] = to_url(base_poster_logo, logo_path) + if backdrop_path: + result["background"] = to_url(base_backdrop, backdrop_path) + return result + @functools.lru_cache(maxsize=128) def get_tmdb_service(language: str = "en-US", api_key: str | None = None) -> TMDBService: From 18cf97d6470df9a10ee8d96ad070367f4bdd2ce7 Mon Sep 17 00:00:00 2001 From: CHAFFY Date: Sun, 29 Mar 2026 11:01:01 +0200 Subject: [PATCH 2/3] :rotating_light: Fix linting and Gemini review --- .pre-commit-config.yaml | 2 +- app/services/recommendation/metadata.py | 13 +++++-------- app/services/tmdb/service.py | 6 ++++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5f57aea..8ca63e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: check-docstring-first - id: detect-private-key - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py311-plus] diff --git a/app/services/recommendation/metadata.py b/app/services/recommendation/metadata.py index e96a120..764c788 100644 --- a/app/services/recommendation/metadata.py +++ b/app/services/recommendation/metadata.py @@ -168,18 +168,15 @@ async def _images_one(d: dict[str, Any]) -> dict[str, str]: except Exception: return {} - image_tasks = [_images_one(d) for d in details_list if d] + successful_details = [d for d in details_list if d] + image_tasks = [_images_one(d) for d in successful_details] images_list = await asyncio.gather(*image_tasks, return_exceptions=True) format_task = [] - for i, details in enumerate(details_list): - if not details: - continue + for details, imgs in zip(successful_details, images_list): logo_url = None - if i < len(images_list): - imgs = images_list[i] - if isinstance(imgs, dict): - logo_url = imgs.get("logo") or None + if isinstance(imgs, dict): + logo_url = imgs.get("logo") or None format_task.append(cls.format_for_stremio(details, media_type, user_settings, logo_url=logo_url)) formatted_list = await asyncio.gather(*format_task, return_exceptions=True) diff --git a/app/services/tmdb/service.py b/app/services/tmdb/service.py index 74cd15e..17dd76c 100644 --- a/app/services/tmdb/service.py +++ b/app/services/tmdb/service.py @@ -139,7 +139,9 @@ async def get_primary_translations(self) -> list[str]: return await self.client.get("/configuration/primary_translations") @alru_cache(maxsize=2000, ttl=86400) - async def get_images(self, media_type: str, tmdb_id: int, include_image_language: str = "en,fr,null") -> dict[str, Any]: + async def get_images( + self, media_type: str, tmdb_id: int, include_image_language: str = "en,fr,null" + ) -> dict[str, Any]: """ Fetch images (posters, logos, backdrops) for a movie or TV show. include_image_language: comma-separated iso_639_1 codes + "null" for language-less images. @@ -164,7 +166,7 @@ def _pick_image_by_language( for lang in preferred_lang_codes: for img in images_list: iso = img.get("iso_639_1") - if (iso or None) == (lang if lang else None): + if iso == lang: path = img.get("file_path") if path: return path From 70d0778ebbe0ef2557f90e426cf7efee21d56a64 Mon Sep 17 00:00:00 2001 From: CHAFFY Date: Sun, 29 Mar 2026 19:18:51 +0200 Subject: [PATCH 3/3] :bug: Fix language selection --- app/services/tmdb/service.py | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/app/services/tmdb/service.py b/app/services/tmdb/service.py index 17dd76c..d4b5b11 100644 --- a/app/services/tmdb/service.py +++ b/app/services/tmdb/service.py @@ -152,6 +152,58 @@ async def get_images( params = {"include_image_language": include_image_language} return await self.client.get(path, params=params) + @staticmethod + def _score_image(img: dict[str, Any]) -> tuple[float, int]: + """Higher is better (TMDB vote fields).""" + va = img.get("vote_average") + vc = img.get("vote_count") + try: + va_f = float(va) if va is not None else 0.0 + except (TypeError, ValueError): + va_f = 0.0 + try: + vc_i = int(vc) if vc is not None else 0 + except (TypeError, ValueError): + vc_i = 0 + return (va_f, vc_i) + + @classmethod + def _pick_best_in_language_bucket( + cls, + images_list: list[dict[str, Any]], + iso: str | None, + ) -> str | None: + """Among images with this iso_639_1 (or None for language-neutral), pick highest-rated.""" + if iso is None: + candidates = [img for img in images_list if img.get("iso_639_1") in (None, "")] + else: + iso_l = iso.lower() + candidates = [img for img in images_list if (img.get("iso_639_1") or "").lower() == iso_l] + if not candidates: + return None + best = max(candidates, key=cls._score_image) + path = best.get("file_path") + return path if path else None + + @classmethod + def _pick_logo_by_language( + cls, + logos: list[dict[str, Any]] | None, + primary_iso: str, + ) -> str | None: + """ + Logo only: exact ISO 639-1 match, else language-neutral (null). No cross-language fallback. + + TMDB tags logos with iso_639_1 only (no region). Falling back to another language (e.g. another + "fr" market's artwork or "en") often mismatches localized titles; omit logo so Metahub/default applies. + """ + if not logos: + return None + p = (primary_iso or "en").lower() + if path := cls._pick_best_in_language_bucket(logos, p): + return path + return cls._pick_best_in_language_bucket(logos, None) + @staticmethod def _pick_image_by_language( images_list: list[dict[str, Any]] | None, @@ -191,8 +243,12 @@ async def get_images_for_title( ) -> dict[str, str]: """ Get poster, logo and background URLs for a title in the requested language. - Same approach as no-stremio-addon: request images with include_image_language, - then pick by preferred language (requested lang, then null, then fallbacks). + + Posters/backdrops: requested language, then null, then common fallbacks (same idea as no-stremio-addon). + + Logos: only the exact ISO 639-1 language or a language-neutral (null) asset — no fallback to other + languages, so we do not show a logo whose text targets another locale when the exact translation + is missing (e.g. another French market). If neither exists, no logo is returned (callers may use Metahub). """ lang = language or self.client.language preferred, include = self._language_to_image_preference(lang) @@ -213,7 +269,8 @@ def to_url(base: str, path: str | None) -> str: backdrops = data.get("backdrops") or [] poster_path = self._pick_image_by_language(posters, preferred) - logo_path = self._pick_image_by_language(logos, preferred) + primary_iso = (lang or "en-US").split("-")[0].lower() if lang else "en" + logo_path = self._pick_logo_by_language(logos, primary_iso) backdrop_path = self._pick_image_by_language(backdrops, preferred) result: dict[str, str] = {}