Skip to content

Commit 6425eab

Browse files
Live channels: background scheduler + authoring/control UI (slices 3-4)
Completes the live-channel feature on top of the engine primitives from the prior commits: a background scheduler that auto-updates channels marked "live", and the UI to author and control it. Ships OFF (recipes_enabled defaults false). Backend — scheduler (backend/scheduler.py, new): - A single in-process asyncio loop started from main.py's lifespan. Wakes every 60s and runs a cycle when recipes_enabled is true, not paused, and recipe_interval_hours has elapsed (re-reads config each tick, so toggles apply within a minute — mirrors how the auth middleware re-reads config per request). - Each cycle (under deploy_lock, blocking I/O offloaded to a worker thread): build the Tunarr index once, then for every "live": true channel, re-resolve its content and diff the fresh program-id set against read_channel_programming (the channel's currently-scheduled set). Patch in place via update_channel_in_place ONLY on a difference. No state file — Tunarr is the source of truth, so the loop survives restarts and an unchanged channel is a cheap no-op (no Plex guide churn). 404 / unreadable / resolves-to-empty are skipped and logged, never fatal. - Writes a rolling diff log to data/logs/recipes.log; keeps last_cycle in memory. - deploy_lock (asyncio.Lock) is shared with pipeline_router: probe/deploy/ deploy-selective now stream through _locked_stream, so a manual deploy and a scheduler cycle never touch Tunarr concurrently. Backend — endpoints (recipes_router.py): - GET /api/recipes/status — enabled, paused, running, interval, live_count, last_cycle - POST /api/recipes/run?apply= — run one cycle on demand; apply=false is a dry run (detect + log, no Tunarr writes). Same code path the loop uses — the test/refresh trigger. - POST /api/recipes/pause?paused= — runtime kill switch, no restart needed. - POST /api/recipes/config — merge-writes recipes_enabled / recipe_interval_hours into config.json. Deliberately separate from the strict /config ConfigModel, which rewrites the whole file and would drop unknown keys. Frontend (slice 4): - Channels page: the edit modal gains a "Live recipe" section — a Switch to mark the channel live, and a franchise auto-match builder. The builder calls /api/recipes/preview and shows matched titles as a checklist; unchecking a title adds it to the rule's exclude (the false-positive escape hatch). Saving stores a {"match":"title_contains", value, order, exclude} entry in the channel's content. A "live" badge marks live channels. - Settings: a "Live Channels" card — master enable Switch + check-interval input, saved via /api/recipes/config. - Dashboard: an "Auto-Updates" card (shown when enabled or any channel is live) — on/paused state, live count + interval, last-cycle changes, Pause/Resume toggle, and a "Check now" button. Verified locally in Docker against live Plex/Tunarr (no mutations): - Scheduler: idempotent no-op cycle = changed:0; positive detection (synthetic live channel) correctly reports adds with human labels (e.g. "Martin x132"); dry-run mutates nothing; loop auto-runs on boot when enabled, idle when disabled. - UI/endpoints: SPA serves; POST /api/recipes/config persists enabled+interval and leaves all connection/auth keys intact (clobber check); status reflects changes; frontend builds clean (tsc + vite). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2237bd6 commit 6425eab

8 files changed

Lines changed: 804 additions & 35 deletions

File tree

backend/main.py

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

21+
import scheduler
2122
from routers import channels_router, config_router, logs_router, pipeline_router, recipes_router, status_router
2223

2324
DATA_DIR = Path(os.environ.get("PROGRAMMARR_DATA", Path(__file__).parent.parent))
@@ -28,7 +29,16 @@
2829
async def lifespan(app: FastAPI):
2930
# Ensure data directories exist before serving any requests
3031
(DATA_DIR / "logs").mkdir(parents=True, exist_ok=True)
31-
yield
32+
# Start the live-channel scheduler loop (no-op unless recipes_enabled in config)
33+
scheduler_task = asyncio.create_task(scheduler.scheduler_loop())
34+
try:
35+
yield
36+
finally:
37+
scheduler_task.cancel()
38+
try:
39+
await scheduler_task
40+
except asyncio.CancelledError:
41+
pass
3242

3343

3444
app = FastAPI(title="Programmarr", docs_url="/api/docs", redoc_url=None, lifespan=lifespan)

backend/routers/pipeline_router.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from fastapi.responses import FileResponse, StreamingResponse
1313
from pydantic import BaseModel
1414

15+
import scheduler # noqa: E402 (backend/ on sys.path) — shared deploy_lock
16+
1517
router = APIRouter()
1618
DATA_DIR = Path(os.environ.get("PROGRAMMARR_DATA", Path(__file__).parent.parent.parent))
1719
SCRIPTS_DIR = Path(os.environ.get("PROGRAMMARR_SCRIPTS", Path(__file__).parent.parent.parent))
@@ -91,6 +93,17 @@ def _sse(gen: AsyncGenerator[str, None]) -> StreamingResponse:
9193
)
9294

9395

96+
async def _locked_stream(script: str, args: list[str], tag: str) -> AsyncGenerator[str, None]:
97+
"""Stream a subprocess while holding the shared deploy_lock for its full duration.
98+
99+
Used for create.py runs (probe/deploy) so the live-channel scheduler can't patch
100+
a channel in the middle of a deploy deleting/recreating it (and vice versa).
101+
"""
102+
async with scheduler.deploy_lock:
103+
async for chunk in _stream(script, args, tag):
104+
yield chunk
105+
106+
94107
class ExportOptions(BaseModel):
95108
no_crossref: bool = False
96109
movie_sections: Optional[list[str]] = None # None = auto-detect; [] = skip type entirely
@@ -272,7 +285,7 @@ async def run_probe(from_channel: Optional[str] = Query(None)):
272285
args = ["--probe"]
273286
if from_channel:
274287
args += ["--from", from_channel]
275-
return _sse(_stream("create.py", args, "probe"))
288+
return _sse(_locked_stream("create.py", args, "probe"))
276289

277290

278291
@router.post("/pipeline/deploy")
@@ -284,7 +297,7 @@ async def run_deploy(from_channel: Optional[str] = Query(None), protected: str =
284297
args += ["--from", from_channel]
285298
if protected:
286299
args += ["--protect", protected]
287-
return _sse(_stream("create.py", args, "deploy"))
300+
return _sse(_locked_stream("create.py", args, "deploy"))
288301

289302

290303
class DeployRequest(BaseModel):
@@ -319,7 +332,7 @@ async def run_deploy_selective(req: DeployRequest):
319332
args.append("--no-delete")
320333
if req.protected_numbers:
321334
args += ["--protect", ",".join(str(n) for n in req.protected_numbers)]
322-
return _sse(_stream("create.py", args, "deploy"))
335+
return _sse(_locked_stream("create.py", args, "deploy"))
323336

324337

325338
@router.post("/pipeline/images")

backend/routers/recipes_router.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pathlib import Path
1313
from typing import Optional
1414

15-
from fastapi import APIRouter, HTTPException
15+
from fastapi import APIRouter, HTTPException, Query
1616
from pydantic import BaseModel
1717

1818
DATA_DIR = Path(os.environ.get("PROGRAMMARR_DATA", Path(__file__).parent.parent.parent))
@@ -24,6 +24,7 @@
2424
sys.path.insert(0, str(SCRIPTS_DIR))
2525

2626
import channel_engine # noqa: E402
27+
import scheduler # noqa: E402 (backend/ is on sys.path)
2728

2829
router = APIRouter()
2930

@@ -66,3 +67,47 @@ def preview_recipe(req: PreviewRequest):
6667
req.value, movie_map, show_map, order=req.order, exclude=req.exclude
6768
)
6869
return {"value": req.value, "order": req.order, "count": len(preview), "matches": preview}
70+
71+
72+
# ── Scheduler control / status ─────────────────────────────────────────────────
73+
74+
@router.get("/recipes/status")
75+
def recipes_status():
76+
"""Current scheduler state: enabled flag, pause, interval, live count, last cycle."""
77+
return scheduler.get_status()
78+
79+
80+
@router.post("/recipes/run")
81+
async def recipes_run(apply: bool = Query(True)):
82+
"""Run one cycle on demand. apply=false is a dry run (detect + log, no patch).
83+
84+
This is the manual-refresh / test trigger — it runs the exact same code path
85+
the background loop does, so you don't have to wait for the interval.
86+
"""
87+
return await scheduler.run_cycle(apply=apply)
88+
89+
90+
@router.post("/recipes/pause")
91+
def recipes_pause(paused: bool = Query(True)):
92+
"""Runtime kill switch — halt (or resume) auto-updates without a restart."""
93+
return scheduler.set_paused(paused)
94+
95+
96+
class RecipeConfigRequest(BaseModel):
97+
enabled: bool
98+
interval_hours: float = 12
99+
100+
101+
@router.post("/recipes/config")
102+
def set_recipe_config(req: RecipeConfigRequest):
103+
"""Persist the master enable flag + interval. Merge-writes config.json so it
104+
never clobbers connection/auth settings (unlike the strict /config model)."""
105+
cfg = _load_config()
106+
cfg["recipes_enabled"] = req.enabled
107+
cfg["recipe_interval_hours"] = req.interval_hours
108+
try:
109+
with open(DATA_DIR / "config.json", "w") as f:
110+
json.dump(cfg, f, indent=4)
111+
except Exception as e:
112+
raise HTTPException(500, f"Could not save config: {e}")
113+
return scheduler.get_status()

0 commit comments

Comments
 (0)