Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion app/api/endpoints/meta.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
6 changes: 4 additions & 2 deletions app/services/recommendation/catalog_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _clean_meta(meta: dict) -> dict | None:
"type",
"name",
"poster",
"logo",
"background",
"description",
"releaseInfo",
Expand All @@ -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


Expand Down
34 changes: 30 additions & 4 deletions app/services/recommendation/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Comment on lines +171 to +183

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a logic error in how details_list is correlated with images_list. images_list is created from a filtered version of details_list (where details is not None), making it potentially shorter. The current loop iterates over details_list with an index i, and uses i to access images_list. This will fail to assign logos for items that appear after a failed detail fetch in the details_list.

To fix this and improve readability, it's better to create a list of successful details first, fetch images for them, and then use zip to iterate over both lists together.

        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 details, imgs in zip(successful_details, images_list):
            logo_url = None
            if isinstance(imgs, dict):
                logo_url = imgs.get("logo")
            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)

Expand Down
85 changes: 85 additions & 0 deletions app/services/tmdb/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The condition to check for language equality can be simplified for better readability. The expressions (iso or None) and (lang if lang else None) are redundant. iso (from img.get(...)) and lang are already effectively str | None, so a direct comparison iso == lang is sufficient and clearer.

Suggested change
if (iso or None) == (lang if lang else None):
if iso == lang:

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:
Expand Down
Loading