|
| 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