11#!/usr/bin/env python3
22"""
3- fetch_images.py — Fetch TMDB logos for single-title channels and set them in Tunarr.
3+ fetch_images.py — Fetch TMDB logos for Tunarr channels and set them in Tunarr.
44
5- Only processes channels with exactly one content item (solo TV show or solo movie).
6- Multi-title channels (TGIF, genre blocks, decades) are skipped.
5+ Solo-title channels (one content item) are searched by that title.
6+ Multi-title channels (network, franchise, entity, genre blocks, etc.) are searched
7+ by channel name using kind-aware strategies loaded from planner_state.json.
78
89Requires "tmdb_api_key" in config.json. Get a free key at https://www.themoviedb.org/settings/api
910
1718
1819import argparse
1920import json
21+ import os
22+ import re
2023import sys
2124import time
2225import urllib .error
2528
2629CONFIG_FILE = "config.json"
2730DEFAULT_CHANNELS_FILE = "channels.json"
31+ PLANNER_STATE_FILE = "planner_state.json"
2832TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"
2933
34+ _FRANCHISE_SUFFIXES = re .compile (
35+ r"\s+(Collection|Series|Franchise|Universe|Saga|Trilogy|Tetralogy|Anthology|Films?|Movies?|Pictures?)\s*$" ,
36+ re .IGNORECASE ,
37+ )
38+ _ENTITY_SUFFIXES = re .compile (r"\s+(Movies|Films?|Pictures?)\s*$" , re .IGNORECASE )
39+ _DIRECTOR_PREFIX = re .compile (r"^Directed\s+by\s+" , re .IGNORECASE )
40+
3041
3142# ── Config ─────────────────────────────────────────────────────────────────────
3243
@@ -47,6 +58,26 @@ def load_config():
4758 return cfg
4859
4960
61+ def load_planner_kind_hints (channels_json_path ):
62+ """Load channel-name -> kind mapping from planner_state.json. Returns {} on failure."""
63+ candidates = [
64+ os .path .join (os .path .dirname (channels_json_path ) or "." , PLANNER_STATE_FILE ),
65+ os .path .join ("data" , PLANNER_STATE_FILE ),
66+ ]
67+ for path in candidates :
68+ try :
69+ with open (path , encoding = "utf-8" ) as f :
70+ ps = json .load (f )
71+ return {
72+ v ["name" ].strip ().lower (): v ["kind" ]
73+ for v in ps .get ("selected" , {}).values ()
74+ if v .get ("name" ) and v .get ("kind" )
75+ }
76+ except (FileNotFoundError , KeyError , json .JSONDecodeError ):
77+ continue
78+ return {}
79+
80+
5081# ── HTTP helpers ───────────────────────────────────────────────────────────────
5182
5283def http_get (url , timeout = 15 ):
@@ -104,6 +135,24 @@ def tmdb_search_movie(title, api_key):
104135 return data ["results" ][0 ]["id" ]
105136
106137
138+ def tmdb_search_company (name , api_key ):
139+ q = urllib .parse .urlencode ({"query" : name , "api_key" : api_key })
140+ data = http_get (f"https://api.themoviedb.org/3/search/company?{ q } " )
141+ if not data or not data .get ("results" ):
142+ return None
143+ return data ["results" ][0 ]["id" ]
144+
145+
146+ def tmdb_company_best_logo (company_id , api_key ):
147+ """Returns logo URL for a TMDB company/network id, or None."""
148+ q = urllib .parse .urlencode ({"api_key" : api_key })
149+ images = http_get (f"https://api.themoviedb.org/3/company/{ company_id } /images?{ q } " )
150+ if not images :
151+ return None
152+ path = tmdb_best_logo (images )
153+ return TMDB_IMAGE_BASE + path if path else None
154+
155+
107156def tmdb_best_logo (images , prefer_lang = "en" ):
108157 """Pick the best logo from a TMDB images response. Returns file_path or None."""
109158 logos = images .get ("logos" , [])
@@ -151,6 +200,91 @@ def fetch_logo_url(title, api_key):
151200 return None , None
152201
153202
203+ def clean_channel_name (name ):
204+ """Strip suffixes/prefixes that obscure the searchable entity name."""
205+ name = _DIRECTOR_PREFIX .sub ("" , name ).strip ()
206+ name = _FRANCHISE_SUFFIXES .sub ("" , name ).strip ()
207+ name = _ENTITY_SUFFIXES .sub ("" , name ).strip ()
208+ return name
209+
210+
211+ def fetch_logo_for_multi (ch_def , kind , api_key ):
212+ """
213+ Fetch a TMDB logo for a multi-title channel using kind-aware search strategies.
214+ Returns (logo_url, label) or (None, None).
215+ """
216+ name = ch_def ["name" ]
217+ cleaned = clean_channel_name (name )
218+ content_strings = [c for c in ch_def .get ("content" , []) if isinstance (c , str )]
219+
220+ def try_tv (title ):
221+ tid = tmdb_search_tv (title , api_key )
222+ if not tid :
223+ return None , None
224+ q = urllib .parse .urlencode ({"api_key" : api_key , "include_image_language" : "en,null" })
225+ images = http_get (f"https://api.themoviedb.org/3/tv/{ tid } /images?{ q } " )
226+ path = tmdb_best_logo (images ) if images else None
227+ return (TMDB_IMAGE_BASE + path , "TV" ) if path else (None , None )
228+
229+ def try_movie (title ):
230+ mid = tmdb_search_movie (title , api_key )
231+ if not mid :
232+ return None , None
233+ q = urllib .parse .urlencode ({"api_key" : api_key , "include_image_language" : "en,null" })
234+ images = http_get (f"https://api.themoviedb.org/3/movie/{ mid } /images?{ q } " )
235+ path = tmdb_best_logo (images ) if images else None
236+ return (TMDB_IMAGE_BASE + path , "Movie" ) if path else (None , None )
237+
238+ def try_company (title ):
239+ cid = tmdb_search_company (title , api_key )
240+ if not cid :
241+ return None , None
242+ url = tmdb_company_best_logo (cid , api_key )
243+ return (url , "Company" ) if url else (None , None )
244+
245+ if kind == "network" :
246+ strategies = [
247+ (try_company , name ),
248+ (try_tv , name ),
249+ (try_movie , name ),
250+ ]
251+ elif kind == "franchise" :
252+ strategies = [
253+ (try_tv , cleaned ),
254+ (try_movie , cleaned ),
255+ ]
256+ if content_strings :
257+ strategies += [
258+ (try_tv , content_strings [0 ]),
259+ (try_movie , content_strings [0 ]),
260+ ]
261+ elif kind in ("director" , "actor" ):
262+ strategies = [
263+ (try_tv , cleaned ),
264+ (try_movie , cleaned ),
265+ (try_company , cleaned ),
266+ ]
267+ else :
268+ strategies = [
269+ (try_tv , name ),
270+ (try_movie , name ),
271+ (try_company , name ),
272+ ]
273+ if cleaned != name :
274+ strategies += [
275+ (try_tv , cleaned ),
276+ (try_movie , cleaned ),
277+ ]
278+
279+ for fn , arg in strategies :
280+ url , label = fn (arg )
281+ if url :
282+ return url , label
283+ time .sleep (0.25 )
284+
285+ return None , None
286+
287+
154288# ── Tunarr channel helpers ─────────────────────────────────────────────────────
155289
156290def get_tunarr_channels (tunarr_url ):
@@ -195,7 +329,7 @@ def clear_channel_icon(tunarr_url, channel, apply):
195329# ── Main ───────────────────────────────────────────────────────────────────────
196330
197331def main ():
198- parser = argparse .ArgumentParser (description = "Fetch TMDB logos for solo-title Tunarr channels" )
332+ parser = argparse .ArgumentParser (description = "Fetch TMDB logos for Tunarr channels" )
199333 parser .add_argument ("--json" , default = DEFAULT_CHANNELS_FILE , help = "channels.json file" )
200334 parser .add_argument ("--apply" , action = "store_true" , help = "Actually update Tunarr (default is dry run)" )
201335 parser .add_argument ("--channel" , type = int , help = "Process only this channel number" )
@@ -237,31 +371,36 @@ def main():
237371 print (f"\n Done: { cleared } icons { 'cleared' if args .apply else 'would be cleared' } " )
238372 return
239373
240- # ── Normal mode: fetch logos for single-content channels ───────────────────
374+ # ── Build channel lists ───────────────────────────────── ───────────────────
241375
242- # Filter to solo-content channels (skip collection references — they're multi-title)
243376 solo = [ch for ch in channels_def
244377 if len (ch .get ("content" , [])) == 1
245378 and isinstance (ch ["content" ][0 ], str )]
379+
380+ multi = [ch for ch in channels_def
381+ if not (len (ch .get ("content" , [])) == 1 and isinstance (ch ["content" ][0 ], str ))]
382+
246383 if args .channel :
247384 solo = [ch for ch in solo if ch ["number" ] == args .channel ]
248- if not solo :
385+ multi = [ch for ch in multi if ch ["number" ] == args .channel ]
386+ if not solo and not multi :
249387 ch_match = next ((c for c in channels_def if c ["number" ] == args .channel ), None )
250388 if ch_match :
251- cnt = len (ch_match .get ("content" , []))
252- print (f"Channel #{ args .channel } has { cnt } content items — only solo channels are supported here." )
389+ print (f"Channel #{ args .channel } not found in processable channels." )
253390 else :
254391 print (f"Channel #{ args .channel } not found in { args .json } " )
255392 sys .exit (1 )
256393
257- print (f"Found { len (solo )} solo-title channel(s) to process \n " )
394+ print (f"Found { len (solo )} solo-title channel(s) and { len ( multi ) } multi-title channel(s) \n " )
258395
259396 print ("Fetching Tunarr channels..." )
260397 tunarr_chs = get_tunarr_channels (tunarr_url )
261398 print ()
262399
263400 stats = {"set" : 0 , "no_logo" : 0 , "no_channel" : 0 , "failed" : 0 }
264401
402+ # ── Solo-title channels ────────────────────────────────────────────────────
403+
265404 for ch_def in solo :
266405 number = ch_def ["number" ]
267406 name = ch_def ["name" ]
@@ -293,6 +432,45 @@ def main():
293432 print (f" -- FAILED to update Tunarr" )
294433 stats ["failed" ] += 1
295434
435+ # ── Multi-title channels ───────────────────────────────────────────────────
436+
437+ if multi :
438+ if solo :
439+ print ()
440+ kind_hints = load_planner_kind_hints (args .json )
441+
442+ for ch_def in multi :
443+ number = ch_def ["number" ]
444+ name = ch_def ["name" ]
445+ kind = kind_hints .get (name .strip ().lower ())
446+
447+ tch = tunarr_chs .get (number )
448+ if not tch :
449+ print (f" SKIP #{ number } { name } — not found in Tunarr (not deployed yet?)" )
450+ stats ["no_channel" ] += 1
451+ continue
452+
453+ kind_label = f" [{ kind } ]" if kind else ""
454+ print (f" #{ number } { name } { kind_label } " , end = "" , flush = True )
455+
456+ logo_url , media_type = fetch_logo_for_multi (ch_def , kind , tmdb_key )
457+ time .sleep (0.25 )
458+
459+ if not logo_url :
460+ print (f" -- no logo found on TMDB" )
461+ stats ["no_logo" ] += 1
462+ continue
463+
464+ ok = update_channel_icon (tunarr_url , tch , logo_url , apply = args .apply )
465+ if ok :
466+ verb = "Set" if args .apply else "[DRY RUN] Would set"
467+ print (f" -- { verb } { media_type } logo" )
468+ print (f" { logo_url } " )
469+ stats ["set" ] += 1
470+ else :
471+ print (f" -- FAILED to update Tunarr" )
472+ stats ["failed" ] += 1
473+
296474 print (f"\n { 'Applied' if args .apply else 'Dry run' } : "
297475 f"{ stats ['set' ]} logos { 'set' if args .apply else 'found' } , "
298476 f"{ stats ['no_logo' ]} not found on TMDB, "
0 commit comments