What this is: the full as-built design, reasoning, and history behind the Live Channels (auto-updating channels) feature. This is reference-when-you-care material — the "why," the paths considered and rejected, and the ideas captured but deliberately not built.
For the short, operational summary an agent needs while working on the code, see the Live Channels section in
../CLAUDE.md. For user-facing behaviour, see../README.md.Status: shipped (v0.2.2+). Per-channel sync metadata, the per-channel "Sync now" button, and Dashboard "next run" were added in a follow-up (see "Per-channel sync state" below).
Make channels self-maintaining. A user's library changes constantly — new
episodes drop weekly, a new franchise film appears once a year. Today channels
are static snapshots: resolution is frozen at deploy time, so they go stale.
A live channel is one whose existing content list is re-resolved against
the Tunarr library on a schedule and patched in place, with no user
intervention.
The user should be able to say "I want a Bad Boys channel" and never touch it again: when Bad Boys 4 lands in the library (and Tunarr has synced it), it's in the channel by the next cycle.
The original draft proposed two bespoke recipe types (tv_loop, franchise)
with their own refresh code paths. That is not the design. The codebase
already resolves content at deploy time (create.py): a show name expands to
all episodes Tunarr currently has (create.py:118-130), and
{"collection":"Name"} refs expand to their members. The only reason channels
go stale is that this resolution never re-runs.
So a live channel is simply "live": true plus the normal content list,
re-resolved on a schedule. The three "types" collapse into one mechanism:
- TV loop (episode growth):
content: ["The Simpsons"]+"live": true. Re-resolving the show name picks up new episodes automatically. No special marker needed. - Collection growth:
content: [{"collection":"…"}]+"live": true. Re-expanding the collection picks up titles Kometa/Trakt added. - Franchise growth: one new content-ref type (below), re-scanned each cycle.
Static title strings, collection refs, and match-refs compose freely in one
content list. Re-resolve grows the dynamic parts and preserves hand-picked
static entries.
Resolution runs against the Tunarr library (build_library_index →
/api/media-libraries/{id}/programs), exactly as create.py does today. Plex
is only queried to expand a collection name into titles, which are then matched
against Tunarr anyway.
Consequence — the freshness chain has a step outside our control: file appears in Plex → Tunarr re-syncs its own library → our cycle can see it → channel patched. UI copy must say "appears once Tunarr has synced," not literally "next morning."
A channel without "live" behaves exactly as today. No recipe object, no
type field, no per-channel interval.
{
"number": 55,
"name": "Bad Boys",
"shuffle": "ordered",
"live": true,
"content": [
{"match": "title_contains", "value": "Bad Boys",
"order": "release_date", "exclude": []}
]
}{
"number": 12,
"name": "The Simpsons",
"shuffle": "ordered",
"live": true,
"content": ["The Simpsons"]
}The match-ref (title_contains) is the one new content item type. order
applies to that ref's matched titles; exclude is a per-ref escape hatch for
false positives.
title_contains is matched on word boundaries, not raw substring (so
"It" does not match Little Women). Even so, title strings cannot always
disambiguate franchises ("It" legitimately matches It Chapter Two,
It Follows, It's Complicated). Safeguards:
- Author-time preview/confirm: when a user sets up a match-ref, the UI shows
exactly which current Tunarr titles match and requires confirmation before
saving (
POST /api/recipes/preview). excludelist: per-ref list of titles to drop, surfaced one click away from the change log when a bad title is auto-added.- Future, principled option:
tmdb_franchise(TMDB collection ID) — authoritative, no title guessing. Deferred.
The LLM does not auto-author live recipes — a human opts a channel in. This keeps the false-positive guard (human confirm) intact.
For order: "release_date", order matched titles by the Tunarr program's date
field if it carries one (confirm against one live /programs response at
build time), else fall back to the Year column in plex_library.csv, else
append unknown-year titles last.
Each cycle, for each live channel: GET its current programming from Tunarr,
freshly resolve its content, and compare the two program-ID sets. Patch only
if they differ. Tunarr is the source of truth — there is no recipe_state.json,
no episode counters. This is idempotent and survives container restarts for free
(important: Watchtower restarts the container on every image update).
Re-POSTing programming regenerates the channel's 30-day schedule and makes Plex re-pull that channel's guide, so the diff gate matters: unchanged channels are a cheap no-op (one GET + a set comparison), not a guide-churning re-post.
Critical. Updates always reuse resolve + build_schedule +
set_programming (POST /api/channels/{id}/programming) against the existing
channel id, looked up by channel number. Never call
delete_channels/create_channel for an update.
Deleting and recreating changes the Tunarr channel ID and breaks the Plex
HDHomeRun DVR mapping — the user has to manually re-add the channel. In-place
updates preserve the ID and number; Plex sees a guide refresh, not a device
change. create.py's delete-and-recreate path is correct for initial deploy
only; the scheduler must never use it.
A single global background loop inside the FastAPI app, started from the
main.py lifespan (alongside the existing WindowsProactorEventLoopPolicy
setup), guarded by recipes_enabled. One loop, one cadence
(recipe_interval_hours, default 12) — no per-channel intervals.
while recipes_enabled and not paused:
async with deploy_lock: # shared with pipeline endpoints
index = build_library_index() # one build per cycle
for ch in channels where ch.live:
fresh = resolve(ch.content, index) # program-id set
try:
cur = GET /api/channels/{id}/programming # by ch.number
except 404:
continue # channel gone (manual deploy); skip
if set(fresh) != set(cur):
set_programming(id, build_schedule(ch.shuffle, fresh))
log_diff(ch, added, removed) # -> data/logs/, Dashboard, badge
sleep(recipe_interval_hours)
Manual deploys run as subprocesses (create.py) that delete/recreate channels
and rewrite channels.json; the scheduler is in-process. A single in-process
asyncio.Lock (deploy_lock) is held by both the scheduler cycle and every
pipeline endpoint that spawns create.py/validate, serializing them. The cycle
also tolerates a 404 (channel mid-delete) and a mid-write channels.json (skip,
retry next cycle).
The CLI path (programmarr.py running create.py directly) is a separate
process the in-process lock cannot cover — running CLI deploys while the web
scheduler is active is a documented "don't."
Each cycle writes a rolling diff log to data/logs/ (per-channel adds/removes,
e.g. #55 Bad Boys +Bad Boys: Ride or Die). The Channels page shows a Live
badge + last-updated time per live channel; the Dashboard shows last cycle time
and recent changes. A wrong auto-add is visible and one click from the exclude
list.
Live channels are authored in the Channels page (Channels.tsx): a per-channel
Live toggle, and for franchises a small builder (enter match value → live
preview of matched titles via POST /api/recipes/preview → confirm → edit
exclude). Toggling Live and confirming patches the channel once immediately
(in-place) so the user sees it work rather than waiting up to a full interval.
Ships off by default behind recipes_enabled: false in config.json. The
user enables it in Settings once trusted. Even when enabled, only channels
marked "live" are touched. A visible "pause auto-updates" control on the
Dashboard halts the loop without a restart.
{
"recipes_enabled": false,
"recipe_interval_hours": 12
}- Refactored resolution into
channel_engine.py, exposingbuild_library_index,resolve_title,build_schedule,set_programmingas importable functions, plus anupdate_channel_in_place(number)path that skips delete/create. - New
backend/scheduler.pystarted frommain.pylifespan, guarded byrecipes_enabled; reusesdeploy_lock. deploy_lockretrofitted into thecreate.py/validate-spawning endpoints inpipeline_router.py.POST /api/recipes/preview(word-boundary match over the live Tunarr index), Channels.tsx builder UI, and the Dashboard/badge surfaces.
- Tunarr program objects do carry
releaseDate(epoch ms),releaseDateString, andyear— sorelease_dateordering reads straight from the Tunarr index; noplex_library.csvfallback was needed. - UI copy reflects the Tunarr-resync dependency (channels page + README note).
- CLI-runs-
create.py-while-web-scheduler-active remains unsupported (documented).
Cosmetic, UI-only per-channel metadata — not used by the diff (correctness
still reads live Tunarr). Kept in its own file so it never races with
channels.json edits or deploys, and never reintroduces the state-file-as-truth
problem the diff design rejects.
- Shape:
{ "<number>": { "checked_at", "changed_at?", "change_summary?" } }. - Written by the scheduler (atomic temp-swap) at the end of apply cycles only
(a dry run isn't a real "sync");
checked_atset for every live channel in the cycle,changed_at/change_summaryonly when that channel was actually patched. Full cycles prune entries for channels no longer live. - Surfaced via
GET /api/recipes/status→channelsmap. The Channels page shows a "synced Xago" note per live row; the Dashboard card shows "next ~in Xh" fromnext_run_seconds(last_auto_runis wall-clock so the sync status handler can compute it).
POST /api/recipes/run accepts only=<number> to scope a cycle to one channel —
the per-channel "Save & Sync now" button in the editor saves the channel then
runs only=N&apply=true, applying the recipe in place without leaving the modal.
Deferred (a future goal, not on the current roadmap). Captured here so the intent
isn't lost. Today everything hard-codes Plex (source) and Tunarr (target);
channel_engine.py already concentrates the touchpoints, which is where an adapter
seam would go:
- Source (library queries):
build_library_index,resolve_collection, episode/title resolution. A future Jellyfin/local source would implement these. - Target (the "in-place update contract"):
find_channel_by_number,read_channel_programming,update_channel_in_place(+ initial create). A future ErsatzTV target would implement the same contract — patch in place, preserve the channel's id/number, never delete-and-recreate.
Do not build a speculative adapter layer until there's a second source/target to validate the abstraction against — with one implementation it will be the wrong shape.
- Not Tunarr Smart Collections. Those are Tunarr's own internal saved-search filters; they don't track your library over time or respond to external sources. Live channels live in Programmarr and drive Tunarr via its API.
- Not a fork of Tunarr. Programmarr stays a target-agnostic layer (see above).
- Not a replacement for the AI / No-AI / Collections generation paths. Those remain how channels are initially created. Live channels are an opt-in freshness layer on top.
new_goals.md (a late-night draft) proposed two bespoke recipe types and writing
the resolved content back into channels.json each cycle. Both were deliberately
superseded: the unified re-resolve model (no recipe.type) and the
no-writeback/diff-vs-Tunarr design above are the permanent approach. That file has
been retired; its still-relevant ideas (agnosticism, "What This Is Not") are folded
in above.
{"match": "franchise", "name": …} joins title_contains as the second live ref type.
Rationale: title matching cannot express franchises whose members share no words (MCU).
Membership is read from the TMDB/Wikidata caches via channel_engine.load_franchise_index
(TMDB wins name collisions, same rule as the Planner merge) and resolved by
match_franchise, which mirrors match_titles' contract — (resolved, preview), shows sort
after movies in release order. The scheduler is unchanged: refs resolve through
resolve_content, the diff/patch cycle is ref-agnostic. Rejected: embedding TMDB/Wikidata
IDs in the ref (cache is name-keyed; IDs differ across sources), scheduler-triggered cache
refresh (circular import with the router layer; revisit in 2b if staleness bites).
Interleaved = random-slot weighting (movie slot chronological at weight n_shows, show slots "next" at weight N) — an average-N approximation, accepted over exact alternation because it reuses Tunarr's scheduler verbatim. Timeline = manual lineup (the only Tunarr type that can express strict cross-media release order); the live diff is unaffected because read_channel_programming extracts content ids from either lineup shape. Rejected: exact movie/episode alternation via generated manual lineups for interleaved too (loses Tunarr's rolling-window randomization and 30-day horizon for no user-visible gain).