Skip to content

Commit 2237bd6

Browse files
Add live-channel engine primitives + franchise-match preview endpoint (slice 2)
Builds the resolution/update layer the live-channel scheduler will sit on top of, plus the author-time preview endpoint. No scheduler yet (slice 3) and no UI yet (slice 4) — this is the engine + one endpoint, all behind no behavior change to existing flows. channel_engine.py additions: - match_titles(value, movie_map, show_map, order, exclude): the franchise matcher for {"match": "title_contains"} content refs. Matches on WORD BOUNDARIES, not raw substring, so "It" matches "It Follows" but never "Little Women". order= "release_date" sorts movies by the Tunarr program releaseDate (epoch ms; recon confirmed Tunarr programs carry releaseDate/year natively, so no plex CSV fallback is needed). exclude is a case-insensitive title drop-list — the per-recipe false-positive escape hatch. Returns (resolved_items, preview) where preview is [{title, year}] for the confirm UI. - resolve_content extended to handle {"match": ...} refs alongside the existing plain-title and {"collection": ...} entries. Collection/plain behavior is byte -identical to slice 1 (verified: create.py --probe still "Done: 80 created"). - find_channel_by_number, read_channel_programming, update_channel_in_place: the in-place update primitives. read_channel_programming returns the set of program IDs currently scheduled (the `programs` dict keys from GET .../programming, which recon confirmed is the same id-space as build_library_index) — the "current" side of the scheduler's change-detection diff. update_channel_in_place looks the channel up by number and POSTs new programming WITHOUT delete/recreate, preserving the Tunarr id and the Plex DVR mapping. backend/routers/recipes_router.py (new) + main.py: - POST /api/recipes/preview — runs match_titles against the live library and returns the matched titles (release-ordered, with years) so the user can confirm a rule before saving it. This is the FIRST in-process import of channel_engine by the backend (the pipeline router shells out to scripts instead), so it also establishes the sys.path wiring (add PROGRAMMARR_SCRIPTS) the scheduler reuses. Verified locally in Docker against live Plex/Tunarr: - POST /api/recipes/preview {"value":"Bad Boys","order":"release_date"} -> the 4 films in release order with years; adding exclude:["Bad Boys II"] -> 3 films. - In-container engine check: find_channel_by_number(10)=Cheers Marathon, read_channel_programming -> 273 ids (matches the show's episode count). - Adversarial match test: "It" -> only real word matches (As Good as It Gets, It's Always Sunny, ...), zero substring garbage. - In-process channel_engine import works inside the image (the /app vs /app/backend path-layout risk) via the PROGRAMMARR_SCRIPTS sys.path insert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fac90ef commit 2237bd6

3 files changed

Lines changed: 196 additions & 2 deletions

File tree

backend/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from fastapi.responses import HTMLResponse, Response
1919
from fastapi.staticfiles import StaticFiles
2020

21-
from routers import channels_router, config_router, logs_router, pipeline_router, status_router
21+
from routers import channels_router, config_router, logs_router, pipeline_router, recipes_router, status_router
2222

2323
DATA_DIR = Path(os.environ.get("PROGRAMMARR_DATA", Path(__file__).parent.parent))
2424
STATIC_DIR = Path(__file__).parent / "static"
@@ -81,6 +81,7 @@ async def auth_middleware(request: Request, call_next):
8181
app.include_router(status_router.router, prefix="/api")
8282
app.include_router(channels_router.router, prefix="/api")
8383
app.include_router(pipeline_router.router, prefix="/api")
84+
app.include_router(recipes_router.router, prefix="/api")
8485
app.include_router(logs_router.router, prefix="/api")
8586

8687
if STATIC_DIR.exists():

backend/routers/recipes_router.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Live-channel recipe endpoints.
2+
3+
Currently exposes the author-time franchise-match preview. This is the first
4+
place the backend imports channel_engine in-process (rather than spawning it as
5+
a subprocess), so it also establishes the sys.path wiring the future scheduler
6+
reuses: SCRIPTS_DIR holds the pipeline scripts but isn't on the path by default.
7+
"""
8+
9+
import json
10+
import os
11+
import sys
12+
from pathlib import Path
13+
from typing import Optional
14+
15+
from fastapi import APIRouter, HTTPException
16+
from pydantic import BaseModel
17+
18+
DATA_DIR = Path(os.environ.get("PROGRAMMARR_DATA", Path(__file__).parent.parent.parent))
19+
SCRIPTS_DIR = Path(os.environ.get("PROGRAMMARR_SCRIPTS", Path(__file__).parent.parent.parent))
20+
21+
# channel_engine.py lives at SCRIPTS_DIR (repo root in dev, /app in Docker), which
22+
# is not on sys.path for the backend process — add it before importing.
23+
if str(SCRIPTS_DIR) not in sys.path:
24+
sys.path.insert(0, str(SCRIPTS_DIR))
25+
26+
import channel_engine # noqa: E402
27+
28+
router = APIRouter()
29+
30+
31+
def _load_config() -> dict:
32+
try:
33+
with open(DATA_DIR / "config.json") as f:
34+
return json.load(f)
35+
except Exception:
36+
return {}
37+
38+
39+
class PreviewRequest(BaseModel):
40+
value: str
41+
exclude: list[str] = []
42+
order: Optional[str] = None
43+
44+
45+
@router.post("/recipes/preview")
46+
def preview_recipe(req: PreviewRequest):
47+
"""Show exactly which current Tunarr titles a title_contains rule matches.
48+
49+
Powers the author-time confirm step: the user sees the matched titles (in the
50+
order they'll air) before saving a live recipe, so a bad rule is caught up front.
51+
"""
52+
if not req.value.strip():
53+
raise HTTPException(400, "match value is required")
54+
55+
cfg = _load_config()
56+
tunarr_url = cfg.get("tunarr_url", "").rstrip("/")
57+
if not tunarr_url:
58+
raise HTTPException(400, "Tunarr not configured")
59+
60+
try:
61+
movie_map, show_map = channel_engine.build_library_index(tunarr_url)
62+
except channel_engine.ChannelEngineError as e:
63+
raise HTTPException(502, f"Tunarr library unavailable: {e}")
64+
65+
_, preview = channel_engine.match_titles(
66+
req.value, movie_map, show_map, order=req.order, exclude=req.exclude
67+
)
68+
return {"value": req.value, "order": req.order, "count": len(preview), "matches": preview}

channel_engine.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
import json
15+
import re
1516
import uuid
1617
import urllib.error
1718
import urllib.request
@@ -135,6 +136,70 @@ def resolve_title(title, movie_map, show_map):
135136
return None
136137

137138

139+
# ── Franchise matching (live recipes) ──────────────────────────────────────────
140+
141+
def _word_boundary_match(value, title):
142+
"""True if `value` appears in `title` on word boundaries (case-insensitive).
143+
144+
Word-boundary, not raw substring: "It" matches "It Follows" but NOT
145+
"Little Women". Multi-word values work too ("Bad Boys" matches "Bad Boys II").
146+
"""
147+
if not value:
148+
return False
149+
return re.search(r"\b" + re.escape(value) + r"\b", title, re.IGNORECASE) is not None
150+
151+
152+
def match_titles(value, movie_map, show_map, order=None, exclude=None):
153+
"""Franchise matcher for {"match": "title_contains"} content refs.
154+
155+
Scans the Tunarr library for titles containing `value` on word boundaries and
156+
returns (resolved_items, preview). `resolved_items` are ready for build_schedule;
157+
`preview` is a [{title, year}] list (same order) for the author-time confirm UI.
158+
159+
order="release_date" sorts movies by releaseDate ascending (unknown dates last);
160+
any other value sorts alphabetically. `exclude` is a case-insensitive list of
161+
titles to drop (the per-recipe false-positive escape hatch).
162+
"""
163+
exclude_set = {e.lower().strip() for e in (exclude or [])}
164+
matched = [] # (sort_release_ms, year, title, item)
165+
166+
for key, p in movie_map.items():
167+
if key in exclude_set:
168+
continue
169+
prog = p.get("program", {})
170+
title = prog.get("title", "")
171+
if _word_boundary_match(value, title):
172+
release_ms = prog.get("releaseDate")
173+
matched.append((
174+
release_ms if release_ms is not None else float("inf"),
175+
prog.get("year"),
176+
title,
177+
{"type": "Movie", "title": title, "programs": [p]},
178+
))
179+
180+
for key, s in show_map.items():
181+
if key in exclude_set:
182+
continue
183+
title = s["title"]
184+
if _word_boundary_match(value, title):
185+
first_prog = s["programs"][0].get("program", {}) if s.get("programs") else {}
186+
matched.append((
187+
float("inf"), # shows have no single release date — sort to the end
188+
first_prog.get("year"),
189+
title,
190+
{"type": "TV", "title": title, "showId": s["showId"], "programs": s["programs"]},
191+
))
192+
193+
if order == "release_date":
194+
matched.sort(key=lambda t: (t[0], t[2].lower()))
195+
else:
196+
matched.sort(key=lambda t: t[2].lower())
197+
198+
resolved = [t[3] for t in matched]
199+
preview = [{"title": t[2], "year": t[1]} for t in matched]
200+
return resolved, preview
201+
202+
138203
# ── Plex collection resolution ─────────────────────────────────────────────────
139204

140205
def get_plex_sections(plex_url, token):
@@ -194,8 +259,9 @@ def resolve_content(content_list, movie_map, show_map,
194259
plex_sections = plex_sections or []
195260
collection_cache = collection_cache if collection_cache is not None else {}
196261

197-
# Expand any {"collection": "Name"} entries to their member titles
262+
# Expand {"collection": "Name"} member titles; {"match": ...} → resolved items
198263
expanded_titles = []
264+
matched_items = []
199265
missing = []
200266
for entry in content_list:
201267
if isinstance(entry, dict) and "collection" in entry:
@@ -207,6 +273,20 @@ def resolve_content(content_list, movie_map, show_map,
207273
else:
208274
print(f" WARNING: Collection '{col_name}' not found in Plex")
209275
missing.append(f"[collection:{col_name}]")
276+
elif isinstance(entry, dict) and "match" in entry:
277+
value = entry.get("value", "")
278+
if entry["match"] == "title_contains" and value:
279+
items, _ = match_titles(value, movie_map, show_map,
280+
order=entry.get("order"), exclude=entry.get("exclude"))
281+
if items:
282+
matched_items.extend(items)
283+
print(f" Match '{value}': {len(items)} titles")
284+
else:
285+
print(f" WARNING: match '{value}' matched nothing in library")
286+
missing.append(f"[match:{value}]")
287+
else:
288+
print(f" WARNING: unsupported match ref: {entry}")
289+
missing.append(f"[match:{value or entry.get('match')}]")
210290
else:
211291
expanded_titles.append(entry)
212292

@@ -218,6 +298,7 @@ def resolve_content(content_list, movie_map, show_map,
218298
else:
219299
missing.append(title)
220300

301+
resolved.extend(matched_items)
221302
return resolved, missing
222303

223304

@@ -277,3 +358,47 @@ def build_schedule(shuffle_type, resolved_items):
277358

278359
def set_programming(tunarr_url, channel_id, schedule_payload):
279360
return api(tunarr_url, "POST", f"/api/channels/{channel_id}/programming", body=schedule_payload, timeout=120)
361+
362+
363+
# ── In-place channel updates (live recipes) ────────────────────────────────────
364+
365+
def find_channel_by_number(tunarr_url, number):
366+
"""Return the live Tunarr channel dict (incl. id) for a channel number, or None."""
367+
for ch in api(tunarr_url, "GET", "/api/channels") or []:
368+
if ch.get("number") == number:
369+
return ch
370+
return None
371+
372+
373+
def read_channel_programming(tunarr_url, channel_id):
374+
"""Return the set of program IDs currently scheduled on a channel, or None on error.
375+
376+
Uses GET /api/channels/{id}/programming. The `programs` field is a dict keyed by
377+
program ID (the same id-space as build_library_index's p["id"]), so its keys are
378+
the current content set. Falls back to distinct content lineup ids if absent.
379+
This set is the "current" side of the scheduler's change-detection diff.
380+
"""
381+
pr = api(tunarr_url, "GET", f"/api/channels/{channel_id}/programming")
382+
if not pr:
383+
return None
384+
programs = pr.get("programs")
385+
if isinstance(programs, dict):
386+
return set(programs.keys())
387+
return {i["id"] for i in pr.get("lineup", []) if i.get("type") == "content" and i.get("id")}
388+
389+
390+
def update_channel_in_place(tunarr_url, number, shuffle, resolved):
391+
"""Patch an existing channel's programming in place — never delete/recreate.
392+
393+
Looks the channel up by number (preserving its Tunarr id and Plex DVR mapping),
394+
rebuilds the schedule from `resolved`, and POSTs it. This is the primitive the
395+
live-channel scheduler calls after detecting a content change. Raises
396+
ChannelEngineError if the channel is missing or no schedule can be built.
397+
"""
398+
ch = find_channel_by_number(tunarr_url, number)
399+
if not ch:
400+
raise ChannelEngineError(f"Channel #{number} not found in Tunarr")
401+
schedule = build_schedule(SHUFFLE_MAP.get(shuffle, "shuffle"), resolved)
402+
if not schedule:
403+
raise ChannelEngineError(f"Channel #{number}: no schedule could be built (no content resolved)")
404+
return set_programming(tunarr_url, ch["id"], schedule)

0 commit comments

Comments
 (0)