Skip to content

Commit fac90ef

Browse files
Extract shared channel_engine.py from create.py (refactor, no behavior change)
Pull the Tunarr channel resolution logic out of create.py into a new, pure, importable module (channel_engine.py) so it can be reused outside the CLI — specifically by the upcoming live-channel scheduler and recipe-preview endpoint, which run inside the long-lived FastAPI process and cannot shell out per call. What moved to channel_engine.py: - HTTP helpers: api, plex_get - library indexing: build_library_index, get_transcode_config, get_plex_source - resolution: resolve_title, get_plex_sections, resolve_collection - scheduling: build_schedule, set_programming - SHUFFLE_MAP constant - NEW resolve_content(): the collection-expand + per-title resolve loop, lifted verbatim out of main() into a reusable (resolved, missing) function. This is the seam where the future {"match": "title_contains"} franchise ref will plug in. create.py becomes a thin CLI wrapper: it imports the engine and keeps only CLI/deploy concerns (load_config, delete_channels, create_channel, argparse main). Its per-channel loop collapses from ~27 lines to a single resolve_content() call. Importability fixes: - build_library_index no longer calls sys.exit() (which would kill the FastAPI process); it raises ChannelEngineError, which create.py's main() translates back into the original "ERROR: ... / exit 1" CLI behavior. - Dockerfile: add channel_engine.py to the pipeline-scripts COPY line. Without this the image's create.py would fail "from channel_engine import ..." with ModuleNotFoundError, breaking every pipeline op in production. Behavior is unchanged. Verified two ways: - bare-Python `create.py --probe`: 992 movies / 47 shows indexed, collection refs expand, "Done: 80 created, 2 skipped" - same probe run INSIDE the built Docker image (cwd=/data, as the backend invokes it): identical output, exit 0, web server boots and /api/status -> 200 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b572604 commit fac90ef

3 files changed

Lines changed: 302 additions & 239 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir -r requirements.txt
2424
COPY backend/ ./backend/
2525

2626
# Pipeline scripts and prompt template
27-
COPY export.py create.py generate_no_ai.py generate_from_collections.py \
27+
COPY export.py create.py channel_engine.py generate_no_ai.py generate_from_collections.py \
2828
fetch_images.py sync_plex.py PROMPT.md ./
2929

3030
# Built React app → served as static files by FastAPI

channel_engine.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
"""
3+
channel_engine.py — Shared Tunarr channel resolution engine.
4+
5+
Pure, importable building blocks shared by create.py (CLI deploy), the live-channel
6+
scheduler, and the recipe-preview endpoint. Every function is parameterized by
7+
tunarr_url / plex_url / token — nothing here reads config.json or touches argv, so
8+
it is safe to import into the FastAPI process. CLI-only concerns (config loading,
9+
delete/create, argparse) stay in create.py.
10+
11+
No dependencies beyond the Python standard library.
12+
"""
13+
14+
import json
15+
import uuid
16+
import urllib.error
17+
import urllib.request
18+
19+
20+
class ChannelEngineError(Exception):
21+
"""Raised for unrecoverable engine conditions (e.g. no Plex source in Tunarr).
22+
23+
Engine code must never call sys.exit() — it can run inside the long-lived
24+
FastAPI process. Callers (create.py main()) translate this into an exit.
25+
"""
26+
27+
28+
SHUFFLE_MAP = {
29+
"ordered": "ordered",
30+
"shuffle": "shuffle",
31+
"block": "block",
32+
}
33+
34+
35+
# ── HTTP helpers ───────────────────────────────────────────────────────────────
36+
37+
def plex_get(base_url, token, path, timeout=60):
38+
sep = "&" if "?" in path else "?"
39+
url = base_url + path + sep + f"X-Plex-Token={token}"
40+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
41+
try:
42+
with urllib.request.urlopen(req, timeout=timeout) as r:
43+
return json.loads(r.read())
44+
except urllib.error.HTTPError as e:
45+
print(f" ! Plex HTTP {e.code} [{path[:60]}]")
46+
return None
47+
except Exception as e:
48+
print(f" ! Plex error [{path[:60]}]: {e}")
49+
return None
50+
51+
52+
def api(tunarr_url, method, path, body=None, timeout=60):
53+
url = tunarr_url + path
54+
data = json.dumps(body).encode() if body is not None else None
55+
headers = {"Accept": "application/json"}
56+
if data:
57+
headers["Content-Type"] = "application/json"
58+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
59+
try:
60+
with urllib.request.urlopen(req, timeout=timeout) as r:
61+
raw = r.read()
62+
return json.loads(raw) if raw.strip() else {}
63+
except urllib.error.HTTPError as e:
64+
raw = e.read().decode(errors="replace")
65+
print(f" ! HTTP {e.code} [{method} {path}]: {raw[:200]}")
66+
return None
67+
except Exception as e:
68+
print(f" ! Error [{method} {path}]: {e}")
69+
return None
70+
71+
72+
# ── Library indexing ───────────────────────────────────────────────────────────
73+
74+
def get_transcode_config(tunarr_url):
75+
configs = api(tunarr_url, "GET", "/api/transcode_configs") or []
76+
return configs[0]["id"] if configs else None
77+
78+
79+
def get_plex_source(tunarr_url):
80+
sources = api(tunarr_url, "GET", "/api/media-sources") or []
81+
return next((s for s in sources if s.get("type") == "plex"), None)
82+
83+
84+
def build_library_index(tunarr_url):
85+
source = get_plex_source(tunarr_url)
86+
if not source:
87+
raise ChannelEngineError("No Plex source found in Tunarr")
88+
89+
libs = source.get("libraries", [])
90+
movie_lib = next((l for l in libs if l.get("mediaType") in ("movie", "movies") and l.get("enabled")), None)
91+
tv_lib = next((l for l in libs if l.get("mediaType") == "shows" and l.get("enabled")), None)
92+
93+
movie_map = {}
94+
show_map = {}
95+
96+
if movie_lib:
97+
print(f" Indexing movie library...")
98+
programs = api(tunarr_url, "GET", f"/api/media-libraries/{movie_lib['id']}/programs", timeout=120) or []
99+
for p in programs:
100+
title = p.get("program", {}).get("title", "")
101+
if title:
102+
movie_map[title.lower().strip()] = p
103+
print(f" Indexed {len(movie_map)} movies")
104+
105+
if tv_lib:
106+
print(f" Indexing TV library...")
107+
programs = api(tunarr_url, "GET", f"/api/media-libraries/{tv_lib['id']}/programs", timeout=120) or []
108+
by_show = {}
109+
for p in programs:
110+
show = p.get("program", {}).get("show", {})
111+
show_id = show.get("uuid") or p.get("program", {}).get("showId")
112+
title = show.get("title", "")
113+
if not show_id or not title:
114+
continue
115+
key = title.lower().strip()
116+
if key not in by_show:
117+
by_show[key] = {"title": title, "showId": show_id, "programs": []}
118+
by_show[key]["programs"].append(p)
119+
show_map = by_show
120+
print(f" Indexed {len(show_map)} TV shows")
121+
122+
return movie_map, show_map
123+
124+
125+
# ── Title resolution ───────────────────────────────────────────────────────────
126+
127+
def resolve_title(title, movie_map, show_map):
128+
key = title.lower().strip()
129+
if key in movie_map:
130+
p = movie_map[key]
131+
return {"type": "Movie", "title": title, "programs": [p]}
132+
if key in show_map:
133+
s = show_map[key]
134+
return {"type": "TV", "title": s["title"], "showId": s["showId"], "programs": s["programs"]}
135+
return None
136+
137+
138+
# ── Plex collection resolution ─────────────────────────────────────────────────
139+
140+
def get_plex_sections(plex_url, token):
141+
data = plex_get(plex_url, token, "/library/sections")
142+
if not data:
143+
return []
144+
return data["MediaContainer"].get("Directory", [])
145+
146+
147+
def resolve_collection(plex_url, token, name, sections, cache):
148+
"""Return a list of titles from a named Plex collection (cached)."""
149+
key = name.lower().strip()
150+
if key in cache:
151+
return cache[key]
152+
153+
titles = []
154+
for section in sections:
155+
section_key = section.get("key")
156+
data = plex_get(plex_url, token, f"/library/sections/{section_key}/collections")
157+
if not data:
158+
continue
159+
collections = data["MediaContainer"].get("Metadata", [])
160+
match = next((c for c in collections if c.get("title", "").lower().strip() == key), None)
161+
if match:
162+
rating_key = match["ratingKey"]
163+
# Some Plex collection types (e.g. Kometa smart collections) return
164+
# size=0 from /library/metadata/{id}/children but work correctly via
165+
# /library/collections/{id}/children — try collections endpoint first.
166+
for children_path in (
167+
f"/library/collections/{rating_key}/children",
168+
f"/library/metadata/{rating_key}/children",
169+
):
170+
items_data = plex_get(plex_url, token, children_path)
171+
if items_data:
172+
items = items_data["MediaContainer"].get("Metadata", [])
173+
titles = [item["title"] for item in items if item.get("title")]
174+
if titles:
175+
break
176+
break
177+
178+
cache[key] = titles
179+
return titles
180+
181+
182+
# ── Content resolution ─────────────────────────────────────────────────────────
183+
184+
def resolve_content(content_list, movie_map, show_map,
185+
plex_url=None, plex_token=None, plex_sections=None, collection_cache=None):
186+
"""Resolve a channel's content list into (resolved_items, missing).
187+
188+
Each entry is either a plain title string or a {"collection": "Name"} ref.
189+
Collection refs are expanded to their member titles via Plex, then every
190+
title is matched against the Tunarr library index. Returns the list of
191+
resolved items (ready for build_schedule) plus the list of titles/refs that
192+
could not be found (for reporting).
193+
"""
194+
plex_sections = plex_sections or []
195+
collection_cache = collection_cache if collection_cache is not None else {}
196+
197+
# Expand any {"collection": "Name"} entries to their member titles
198+
expanded_titles = []
199+
missing = []
200+
for entry in content_list:
201+
if isinstance(entry, dict) and "collection" in entry:
202+
col_name = entry["collection"]
203+
col_titles = resolve_collection(plex_url, plex_token, col_name, plex_sections, collection_cache)
204+
if col_titles:
205+
expanded_titles.extend(col_titles)
206+
print(f" Collection '{col_name}': {len(col_titles)} titles")
207+
else:
208+
print(f" WARNING: Collection '{col_name}' not found in Plex")
209+
missing.append(f"[collection:{col_name}]")
210+
else:
211+
expanded_titles.append(entry)
212+
213+
resolved = []
214+
for title in expanded_titles:
215+
item = resolve_title(title, movie_map, show_map)
216+
if item:
217+
resolved.append(item)
218+
else:
219+
missing.append(title)
220+
221+
return resolved, missing
222+
223+
224+
# ── Schedule builder ───────────────────────────────────────────────────────────
225+
226+
def build_schedule(shuffle_type, resolved_items):
227+
all_programs = [p for item in resolved_items for p in item["programs"]]
228+
if not all_programs:
229+
return None
230+
231+
is_ordered = shuffle_type == "ordered"
232+
is_block = shuffle_type == "block"
233+
234+
slots = []
235+
seen_show_ids = set()
236+
has_movies = False
237+
238+
for item in resolved_items:
239+
if item["type"] == "TV":
240+
show_id = item.get("showId")
241+
if show_id and show_id not in seen_show_ids:
242+
seen_show_ids.add(show_id)
243+
slots.append({
244+
"type": "show",
245+
"id": str(uuid.uuid4()),
246+
"cooldownMs": 0,
247+
"weight": 1,
248+
"order": "next" if (is_ordered or is_block) else "shuffle",
249+
"showId": show_id,
250+
})
251+
else:
252+
has_movies = True
253+
254+
if has_movies:
255+
slots.append({
256+
"type": "movie",
257+
"id": str(uuid.uuid4()),
258+
"cooldownMs": 0,
259+
"weight": 1,
260+
"order": "chronological" if is_ordered else "shuffle",
261+
})
262+
263+
return {
264+
"type": "random",
265+
"programs": [p["id"] for p in all_programs],
266+
"schedule": {
267+
"type": "random",
268+
"flexPreference": "end",
269+
"maxDays": 30,
270+
"padMs": 0,
271+
"padStyle": "episode",
272+
"randomDistribution": "uniform",
273+
"slots": slots,
274+
},
275+
}
276+
277+
278+
def set_programming(tunarr_url, channel_id, schedule_payload):
279+
return api(tunarr_url, "POST", f"/api/channels/{channel_id}/programming", body=schedule_payload, timeout=120)

0 commit comments

Comments
 (0)