From 61b775f85fe45f008380c970a962049e7c9da914 Mon Sep 17 00:00:00 2001 From: Eric Green Date: Thu, 3 Sep 2026 17:06:01 -0400 Subject: [PATCH 1/2] Serve show metadata from TMDB when TVDB and Trakt are unavailable Show metadata had exactly two backends: TVDB (needs an API key) and Trakt (needs an authenticated account). TMDB was used for movie release dates and alias enrichment but never for shows, so a setup with only a TMDB key got "No metadata returned" for every show while movies worked fine. _fetch_tmdb_show_data already existed but was unreachable in practice and returned seasons with no episodes. Two changes make TMDB a real backend: - _fetch_tmdb_episodes fetches each season from TMDB and maps it to the same {season: {episode: {title, overview, runtime, first_aired, imdb_id, absolute}}} shape _fetch_episodes_paginated produces, and _fetch_tmdb_show_data now populates episodes with it. Without episodes a show cannot expand into wanted items, so show-level data alone was no more useful than none. - get_show_data and get_show_seasons_and_episodes go straight to TMDB when is_available() is False. Previously they resolved a TVDB id via TMDB and then made a TVDB request that needs the key they don't have, whose failure path falls back to Trakt. _get_metadata_client picks tvdb_client (for its TMDB path) when TMDB is configured and TVDB is not, and the source name reports 'tmdb'. TMDB gives air_date without a time or network timezone, so first_aired is date-only, as it already is for TVDB shows without airsTime. Co-Authored-By: Claude Opus 5 --- cli_battery/app/direct_api.py | 25 +++++++-- cli_battery/app/tvdb_client.py | 99 ++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/cli_battery/app/direct_api.py b/cli_battery/app/direct_api.py index b761dd89..3f1c9785 100644 --- a/cli_battery/app/direct_api.py +++ b/cli_battery/app/direct_api.py @@ -68,16 +68,33 @@ def _ensure_worker(): logger.warning(f"Could not start refresh worker: {e}") -def _get_metadata_client(): - """Return tvdb_client if TVDB API key is set, else trakt_client.""" +def _tmdb_only_available() -> bool: + """True when there is no TVDB key but TMDB is configured. + + tvdb_client serves shows entirely from TMDB in that case, which keeps TV + working on setups that have neither a TVDB key nor a Trakt account. + """ if tvdb_client.is_available(): + return False + try: + from utilities.settings import get_setting + return bool((get_setting('TMDB', 'api_key', default='') or '').strip()) + except Exception: + return False + + +def _get_metadata_client(): + """Return tvdb_client if TVDB or TMDB is configured, else trakt_client.""" + if tvdb_client.is_available() or _tmdb_only_available(): return tvdb_client return trakt_client def _get_metadata_source_name() -> str: - """Return 'tvdb' or 'trakt' depending on which client is active.""" - return 'tvdb' if tvdb_client.is_available() else 'trakt' + """Return 'tvdb', 'tmdb' or 'trakt' depending on which source is active.""" + if tvdb_client.is_available(): + return 'tvdb' + return 'tmdb' if _tmdb_only_available() else 'trakt' def _get_local_tz(): diff --git a/cli_battery/app/tvdb_client.py b/cli_battery/app/tvdb_client.py index ec21ced4..5a0fada5 100644 --- a/cli_battery/app/tvdb_client.py +++ b/cli_battery/app/tvdb_client.py @@ -604,6 +604,16 @@ def _get_trakt_status(imdb_id: str) -> Optional[str]: def get_show_data(imdb_id: str) -> Optional[dict]: """Get full show metadata + aliases + seasons/episodes.""" + # No usable TVDB key: serve the whole show from TMDB rather than resolving a + # TVDB id we cannot then fetch with. Resolving one via TMDB and continuing + # would send us into the TVDB request below, whose failure path falls back to + # Trakt - useless on a setup that has neither. + if not is_available(): + tmdb_api_key = _get_tmdb_api_key() + if tmdb_api_key: + return _fetch_tmdb_show_data(imdb_id, tmdb_api_key) + return None + tvdb_id = _resolve_tvdb_id(imdb_id, media_type='show') if not tvdb_id: # TVDB hasn't linked this IMDb ID yet — try resolving via TMDB ID @@ -991,6 +1001,28 @@ def _extract_seasons_from_extended(raw: dict) -> Optional[dict]: def get_show_seasons_and_episodes(imdb_id: str, include_specials: bool = False) -> Tuple[Optional[dict], Optional[str]]: """Fetch seasons and episodes for a show.""" + # No usable TVDB key: build seasons from TMDB. See get_show_data above. + if not is_available(): + tmdb_api_key = _get_tmdb_api_key() + if not tmdb_api_key: + return None, None + show = _fetch_tmdb_show_data(imdb_id, tmdb_api_key) + if not show: + return None, None + seasons = {} + for sn_str, sdata in (show.get('seasons') or {}).items(): + try: + sn = int(sn_str) + except (TypeError, ValueError): + continue + seasons[sn] = { + 'episode_count': sdata.get('episode_count', 0), + 'episodes': sdata.get('episodes', {}), + } + if seasons and not include_specials: + seasons.pop(0, None) + return (seasons, 'tmdb') if seasons else (None, None) + tvdb_id = _resolve_tvdb_id(imdb_id, media_type='show') if not tvdb_id: logger.warning(f"TVDB: could not resolve IMDb {imdb_id} to TVDB ID for episodes, trying Trakt fallback") @@ -1531,6 +1563,59 @@ def _fetch_tmdb_movie_data(imdb_id: str, api_key: str) -> Optional[dict]: return None +def _fetch_tmdb_episodes(tmdb_id: int, api_key: str, + season_numbers: List[Optional[int]]) -> Optional[dict]: + """Fetch episodes per season from TMDB, shaped like _fetch_episodes_paginated. + + Returns {season_number: {episode_number: {title, overview, runtime, + first_aired, imdb_id, absolute}}}. + + TMDB gives air_date as a plain YYYY-MM-DD with no air time or network + timezone, so first_aired is formatted date-only. Downstream treats a + date-only value the same way it does for TVDB shows lacking airsTime. + """ + if not tmdb_id: + return None + + by_season: dict = {} + for sn in season_numbers: + if sn is None: + continue + try: + resp = requests.get( + f"https://api.themoviedb.org/3/tv/{tmdb_id}/season/{sn}", + params={'api_key': api_key}, + timeout=REQUEST_TIMEOUT, + ) + if resp.status_code != 200: + logger.debug(f"TMDB season {sn} fetch failed for tmdb_id={tmdb_id}: {resp.status_code}") + continue + + ep_dict: dict = {} + for ep in (resp.json().get('episodes') or []): + ep_num = ep.get('episode_number') + if ep_num is None: + continue + ep_dict[ep_num] = { + 'title': ep.get('name', '') or '', + 'overview': ep.get('overview', '') or '', + 'runtime': ep.get('runtime') or 0, + 'first_aired': _format_air_date(ep.get('air_date')), + 'imdb_id': None, + 'absolute': None, + } + if ep_dict: + by_season[sn] = ep_dict + except Exception as e: + logger.debug(f"TMDB season {sn} error for tmdb_id={tmdb_id}: {e}") + + if by_season: + total = sum(len(v) for v in by_season.values()) + logger.info(f"TMDB: fetched {total} episode(s) across {len(by_season)} season(s) " + f"for tmdb_id={tmdb_id}") + return by_season or None + + def _fetch_tmdb_show_data(imdb_id: str, api_key: str) -> Optional[dict]: """Fetch show metadata from TMDB as fallback when TVDB cannot resolve the ID.""" tmdb_id = _resolve_tmdb_id_from_imdb(imdb_id, api_key, media_type='show') @@ -1570,15 +1655,23 @@ def _fetch_tmdb_show_data(imdb_id: str, api_key: str) -> Optional[dict]: genres = [g.get('name', '') for g in (raw.get('genres') or []) if isinstance(g, dict)] - # Build seasons dict + # Build seasons dict, populating episodes from TMDB. Without episodes + # the show cannot expand into wanted items, so a show-level-only result + # is no more useful than none at all. + episodes_by_season = _fetch_tmdb_episodes( + tmdb_id, api_key, + [s.get('season_number') for s in (raw.get('seasons') or [])], + ) or {} + seasons = {} for s in (raw.get('seasons') or []): sn = s.get('season_number') if sn is not None: + fetched = episodes_by_season.get(sn) or {} seasons[str(sn)] = { 'number': sn, - 'episode_count': s.get('episode_count', 0), - 'episodes': {}, + 'episode_count': len(fetched) or s.get('episode_count', 0), + 'episodes': fetched, } data = { From 36b6684443ad55452dfec4beca4c1cf74cabca82 Mon Sep 17 00:00:00 2001 From: Eric Green Date: Fri, 4 Sep 2026 11:45:18 -0400 Subject: [PATCH 2/2] Prefer Trakt over TMDB for shows when it is configured The previous commit selected the TMDB path whenever a TVDB key was absent and a TMDB key present, without asking whether Trakt was set up. A TMDB key is commonly configured for posters and release dates, so a working Trakt install with no TVDB key would have been silently downgraded to TMDB, which has no per-episode IMDb ids, no absolute numbering and date-only air times. _refresh_show only falls back to Trakt when the primary returns nothing, so partial-but-poorer data would have won. Gate the TMDB path on Trakt being absent too, via tvdb_client.tmdb_only_mode(), and route get_show_data / get_show_seasons_and_episodes to Trakt directly when there is no TVDB key but Trakt is configured. discover_routes calls both functions without going through _get_metadata_client, so the guard belongs in them rather than only in the selector. The check reads the stored Trakt tokens rather than calling trakt_auth.is_authenticated(), which can trigger a network token refresh and runs on every client selection. Co-Authored-By: Claude Opus 5 --- cli_battery/app/direct_api.py | 14 ++++----- cli_battery/app/tvdb_client.py | 53 +++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/cli_battery/app/direct_api.py b/cli_battery/app/direct_api.py index 3f1c9785..d4bdb554 100644 --- a/cli_battery/app/direct_api.py +++ b/cli_battery/app/direct_api.py @@ -69,18 +69,14 @@ def _ensure_worker(): def _tmdb_only_available() -> bool: - """True when there is no TVDB key but TMDB is configured. + """True when shows must come from TMDB: no TVDB key, no Trakt tokens. tvdb_client serves shows entirely from TMDB in that case, which keeps TV - working on setups that have neither a TVDB key nor a Trakt account. + working on setups that have neither a TVDB key nor a Trakt account. Trakt + wins when configured - TMDB has no per-episode IMDb ids, no absolute + numbering, and date-only air times. """ - if tvdb_client.is_available(): - return False - try: - from utilities.settings import get_setting - return bool((get_setting('TMDB', 'api_key', default='') or '').strip()) - except Exception: - return False + return tvdb_client.tmdb_only_mode() def _get_metadata_client(): diff --git a/cli_battery/app/tvdb_client.py b/cli_battery/app/tvdb_client.py index 5a0fada5..b29ce26e 100644 --- a/cli_battery/app/tvdb_client.py +++ b/cli_battery/app/tvdb_client.py @@ -118,6 +118,32 @@ def is_available() -> bool: return False +def _trakt_configured() -> bool: + """True when Trakt has stored tokens. + + Deliberately does not call trakt_auth.is_authenticated(), which can fire a + network token refresh - this runs on every metadata client selection. + """ + try: + from utilities.settings import get_setting + return bool((get_setting('Trakt', 'access_token', default='') or '').strip() + or (get_setting('Trakt', 'refresh_token', default='') or '').strip()) + except Exception: + return False + + +def tmdb_only_mode() -> bool: + """True when shows can only be served from TMDB. + + Requires a TMDB key and the absence of both richer sources. Trakt is + preferred over TMDB when configured: TMDB gives no per-episode IMDb ids, no + absolute numbering, and date-only air times. + """ + if is_available() or _trakt_configured(): + return False + return bool(_get_tmdb_api_key()) + + def _ensure_token() -> bool: """Authenticate with TVDB and cache the bearer token. Returns True on success.""" global _token @@ -604,14 +630,15 @@ def _get_trakt_status(imdb_id: str) -> Optional[str]: def get_show_data(imdb_id: str) -> Optional[dict]: """Get full show metadata + aliases + seasons/episodes.""" - # No usable TVDB key: serve the whole show from TMDB rather than resolving a - # TVDB id we cannot then fetch with. Resolving one via TMDB and continuing - # would send us into the TVDB request below, whose failure path falls back to - # Trakt - useless on a setup that has neither. + # No usable TVDB key: don't resolve a TVDB id we cannot then fetch with. + # Resolving one via TMDB and continuing would send us into the TVDB request + # below, which needs the key we don't have. if not is_available(): - tmdb_api_key = _get_tmdb_api_key() - if tmdb_api_key: - return _fetch_tmdb_show_data(imdb_id, tmdb_api_key) + if tmdb_only_mode(): + return _fetch_tmdb_show_data(imdb_id, _get_tmdb_api_key()) + if _trakt_configured(): + from . import trakt_client + return trakt_client.get_show_data(imdb_id) return None tvdb_id = _resolve_tvdb_id(imdb_id, media_type='show') @@ -1001,12 +1028,16 @@ def _extract_seasons_from_extended(raw: dict) -> Optional[dict]: def get_show_seasons_and_episodes(imdb_id: str, include_specials: bool = False) -> Tuple[Optional[dict], Optional[str]]: """Fetch seasons and episodes for a show.""" - # No usable TVDB key: build seasons from TMDB. See get_show_data above. + # No usable TVDB key: prefer Trakt, else build seasons from TMDB. + # See get_show_data above. if not is_available(): - tmdb_api_key = _get_tmdb_api_key() - if not tmdb_api_key: + if not tmdb_only_mode(): + if _trakt_configured(): + from . import trakt_client + return trakt_client.get_show_seasons_and_episodes( + imdb_id, include_specials=include_specials) return None, None - show = _fetch_tmdb_show_data(imdb_id, tmdb_api_key) + show = _fetch_tmdb_show_data(imdb_id, _get_tmdb_api_key()) if not show: return None, None seasons = {}