Skip to content

Commit 15fabff

Browse files
feat(fetch-images): expand logo fetching to multi-title channels
Adds kind-aware TMDB search strategies for network, franchise, entity, and generic multi-title channels. Network channels try company/network search first; franchise channels try the cleaned name then first content item; director/actor channels use the cleaned name; everything else falls back through TV → movie → company. Kind hints are loaded optionally from planner_state.json; missing file is a safe no-op. Solo-title path is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f446ef7 commit 15fabff

1 file changed

Lines changed: 188 additions & 10 deletions

File tree

fetch_images.py

Lines changed: 188 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
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
89
Requires "tmdb_api_key" in config.json. Get a free key at https://www.themoviedb.org/settings/api
910
@@ -17,6 +18,8 @@
1718

1819
import argparse
1920
import json
21+
import os
22+
import re
2023
import sys
2124
import time
2225
import urllib.error
@@ -25,8 +28,16 @@
2528

2629
CONFIG_FILE = "config.json"
2730
DEFAULT_CHANNELS_FILE = "channels.json"
31+
PLANNER_STATE_FILE = "planner_state.json"
2832
TMDB_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

5283
def 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+
107156
def 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

156290
def get_tunarr_channels(tunarr_url):
@@ -195,7 +329,7 @@ def clear_channel_icon(tunarr_url, channel, apply):
195329
# ── Main ───────────────────────────────────────────────────────────────────────
196330

197331
def 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"\nDone: {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

Comments
 (0)