Imported from fudoniten/tunarr-scheduler (
AGENTS.md). Install upstream withnpx skills add fudoniten/tunarr-scheduler. Copyright stays with the author.
AGENTS.md — Tunarr Scheduler
Working notes for AI agents and humans working on this repo. For a deeper tour, see README.md, ROADMAP.md, and
PSEUDOVISION_INTEGRATION.md.
What this is
Tunarr Scheduler is the control plane for the Fudo stack. It is a Clojure service that:
- Pulls media metadata from Jellyfin (catalog sync).
- Calls Tunabrain (LLM gateway) to tag and categorise media.
- Computes and freezes quarterly grids + monthly overrides per channel.
- Runs a deterministic weekly expander that projects the grid onto a date range and pushes concrete DailySlots to Pseudovision.
- Drives the bumper pipeline (LLM script → TTS → ffmpeg → Grout).
Pseudovision owns the playout timeline. Tunarr Scheduler decides what to schedule. Tunabrain supplies the LLM reasoning. Tunarr Scheduler is stateful; Tunabrain is stateless.
How it fits in the ecosystem
Jellyfin ──► Tunarr Scheduler ──► Pseudovision ──► HLS stream
│ │
▼ ▼
Tunabrain Grout (bumpers)
(LLM) (filler)
▲
│
Marquee (UI, optional)
- Upstream: Jellyfin (
jellyfin.kube.sea.fudo.link) is the source of truth for media. Tunarr Scheduler reads library metadata, episodes, and tags from it. - Outbound writer: Pseudovision
(
pseudovision.kube.sea.fudo.link) is the only writer fordaily-slotsand channel-tags in the live cluster. Tunarr Scheduler never edits PV directly outside of these two flows. - LLM dependency: Tunabrain (
tunabrain.kube.sea.fudo.link) is invoked for tagging, channel-mapping, propose-grid, repair-grid, and propose-overrides. - Bumper target: Grout (
grout.pseudovision.svc.cluster.local:8080, in-cluster only) for LLM-generated bumpers. Tunarr Scheduler's8df074dswitched the bumper pipeline from Jellyfin to Grout.
Live endpoints (cluster)
| Service | URL | Notes |
|---|---|---|
| Public HTTPS | https://tunarr-scheduler.kube.sea.fudo.link |
Ingress via cert-manager |
| Version | GET /api/version |
{git-commit, git-timestamp, version-tag} |
| OpenAPI | GET /openapi.json |
Reitit-generated; ~30 endpoints (5 cron-driven + 5 scheduling + dimensions + tags + jobs) |
| Health | none at root | Use /api/version as a liveness probe |
| In-cluster | tunarr-scheduler.arr.svc.cluster.local:3000 |
In the arr namespace; Pseudovision calls into this when migrating media |
Deployed as of 2026-07-03: c08c4c0 (most recent, includes the Grout
bumper migration). Live cluster and upstream/master are at the same SHA.
Local development
# Run
clojure -M:run --config resources/config.edn
clojure -M:run --config resources/config.edn --log-level debug
clojure -M:run --config base.edn --config prod-overrides.edn # layered config
# Tests (eftest)
clojure -M:test
clojure -M:test --focus tunarr.scheduler.scheduling.expander-test
# nREPL
clojure -M:repl
Tooling:
- Clojure CLI (deps.edn-based)
- Java 21+
- The service expects a
pseudovisionconfig block pointing at PV's base URL and atunabrainconfig block. Seeresources/config.edn.
Source layout
src/tunarr/scheduler/
├── main.clj ; CLI / system boot
├── system.clj ; integrant system map (wires everything together)
├── config.clj ; Aero + layered config loading
├── http/ ; reitit routes
│ ├── core.clj ; the router
│ ├── api/ ; one ns per resource
│ │ ├── scheduling.clj ; /api/scheduling/{daily|weekly|monthly|quarterly}
│ │ ├── plans.clj ; /api/scheduling/channels/{ch}/{grid|overrides|plan|preview|guidance}
│ │ ├── media.clj ; recategorize, retag, sync-pseudovision
│ │ ├── dimensions.clj ; /api/dimensions/*
│ │ ├── tags.clj ; /api/tags
│ │ ├── jobs.clj ; /api/jobs/* (async job runner)
│ │ └── ...
│ └── ...
├── scheduling/
│ ├── orchestration.clj ; run-quarterly! / run-monthly! / sync-native-schedule!
│ ├── native_schedule.clj ; Grid -> PV native schedule/slot translator (base
│ │ grids; called from run-quarterly! via
│ │ sync-native-schedule! when pv-channel-id is given)
│ ├── integration.clj ; publish-week! / publish-daily-slots! (talks to PV;
│ │ still the only path for monthly Overrides — see
│ │ ROADMAP.md's "DailySlot ingestion" open item)
│ ├── expander.clj ; deterministic grid + overrides → DailySlot[]
│ ├── contracts.clj ; Malli schemas (CatalogProfile, Grid, Override, DailySlot)
│ ├── feasibility.clj ; pre-flight capacity checks
│ ├── candidates.clj ; duration-feasible slot-tiling menu (opt-in, not
│ │ yet the default — see DURATION_AWARE_SCHEDULING.md)
│ ├── plans.clj ; preview + storage layer for frozen grids
│ └── tasks.clj ; cron-driven task entry points (also defines
│ run-daily!/run-weekly!, not just thin wrappers
│ around orchestration.clj)
├── backends/
│ └── pseudovision/
│ └── client.clj ; the PV HTTP client; push-daily-slots! lives here
├── curation/ ; LLM-driven tagging + categorisation
├── media/ ; Jellyfin client + per-library operations
├── channels/ ; channel definitions, config keys
├── tunabrain.clj ; Tunabrain HTTP client
├── bumpers.clj ; bumper orchestration
└── tts.clj ; TTS abstraction (used by bumpers)
Public API surface (high-traffic endpoints)
The 5 cron-driven endpoints (per the tunarr-scheduler-scheduling-redesign.md):
| Endpoint | Purpose | Caller |
|---|---|---|
POST /api/scheduling/daily |
Extend the playout horizon (1-2 days) | K8s CronJob (frequent) |
POST /api/scheduling/weekly |
Re-apply schedule templates to every channel (deterministic) | K8s CronJob (weekly) |
POST /api/scheduling/monthly |
LLM-proposed sparse overrides | K8s CronJob (monthly) |
POST /api/scheduling/quarterly |
LLM-proposed frozen weekly grid + repair loop, plus syncing the frozen grid onto Pseudovision's native schedule/slot engine (sync-native-schedule!) when a pv-channel-id is resolvable |
K8s CronJob (quarterly) |
GET /api/scheduling/channels |
List channels with stored plan/guidance | Marquee |
weekly's "re-apply schedule templates" description is now only half the story for a channel whose base grid has been native-synced: Pseudovision's own horizon extension (daily) keeps that channel's timeline full without a weekly DailySlot push. weekly remains the only delivery mechanism for monthly Overrides, so it's still live and cron-wired — see ROADMAP.md's "DailySlot ingestion" open item for the unresolved overlap.
Plus the per-channel control plane (under /api/scheduling/channels/{slug}/):
grid, overrides, plan, preview, guidance (PUT/GET).
The dimensions and tags endpoints are the truth source for channel-tagged media:
GET /api/dimensions—[{name, value-count}, ...]GET /api/dimensions/channel/values—[{value, usage-count}, ...]GET /api/dimensions/channel/values/{slug}/media— concrete media for a channel. Use this, notGET /api/catalog/aggregate?channel=X— the latter lives in PV and was the source of Issue 4.GET /api/tags,GET /api/tags/{tag}/media— flat tag inventory (nochannel:*prefix in tag names; the:prefix is a display convention in dimensions, not stored inmedia_tags).
Full schema: GET /openapi.json.
Common pitfalls
- Pattern 8a — sync the fork before debugging.
~/repos/tunarr-scheduleris thefudohermespersonal fork. Active work happens onfudoniten/tunarr-schedulerupstream. Local WIP (uncommitted files, draft branches) is not what's deployed. Always rungit fetch upstream && git log upstream/master --since="14 days ago"first. The Jul 2026 changes digest (references/tunarr-scheduler-july-2026-changes.md) is the second stop. - The 4e8fb6b WARN is the most common "I never saw this before" cause.
When weekly scheduling silently fails, this WARN surfaces it:
publish-week!: pushed daily slots but NONE were ingested. Look at it before assuming a code regression. If it's there, the failure is in Pseudovision'sdaily-slotshandler (e.g. thecategory_filtersshow-vs-episode bug fixed in PV PR #114), not in this repo. channel-taginjection is the cause AND the cure. Scheduler addschannel:<slug>to every slot'scategory_filters(viatasks.clj:39 channel-catalog-tagandintegration.clj:publish-daily-slots!). If PV's filter doesn't honour it, every weekly push is silently rejected. This is the same plumbing that exposes the underlying PV bug, and fixing the PV side makes scheduling work again.channel-namesinmedia_itemsis empty. The legacy hardcoded field is no longer the source of truth. Channel membership lives inmedia_categorization(the dimension table) and is flattened intomedia_tagsas thechannel:<slug>tag at sync time. Don't readmedia.channel_namesto filter — use the dimensions endpoint.channel_idsare slug strings, not integers. Tunarr Scheduler'schannelquery param takes the config-key slug (goldenreels), not the display name (Golden Reels) and not PV's integer id. Thechannel_idquery param takes PV's integer id. Internally, storage keys on the TSchannel.idUUID (not the slug, not the display name, not the PV integer id) — see pitfall 8.- Tunabrain responses can have explicit
nullin scope fields. Strip them before validating againstOverride(commit1caf909). TheTunabrain → Overridestep inscheduling/orchestration.cljis the place to look. - Heredocs and shell-interpretation can corrupt OpenAPI generation. The
reitit OpenAPI spec is generated at boot from the route table; if routes
are added inside
with-redefsblocks in tests, they won't appear in the spec. Define routes at the top level, then add the handler impls. - Storage uses
channel.idUUID, NOT the display name. Until the July 2026 fix,freeze-grid!/store-overrides!/set-guidance!stored the::media/channel-fullname(e.g. "Enigma TV") ingrids.channeletc. That collided with the slug used in HTTP URLs (/api/scheduling/channels/enigma/grid— exact-matchWHERE channel = ?returned 0 rows because the stored value was "Enigma TV", not "enigma") and even withchannel-storage-uuiddoing slug→fullname translation, case mismatches and slug-with-spaces values ("enigma tv", "golden reels") in olderchannel_guidancerows still failed. The fix is to use the TSchannel.idUUID as the canonical storage key. All new code must pass::media/channel-uuidto the storage functions; passing::media/channel-fullnameis a bug. The pre-migration data in thegrids/overrides/channel_guidancetables is a one-shot migration target — seereferences/ts-storage-channel-uuid-2026-07.md(the SQL lives at/opt/media/2026-07-10-ts-channel-uuid-migration.*.sqlon the hermes-shares sidecar). - Tunabrain is now in
arrnamespace, notmedia. When looking up the service, usetunabrain.arr.svc.cluster.local:5546(in-cluster) ortunabrain.kube.sea.fudo.link(ingress). The legacymedianamespace service was a sidecar-only stub that had no endpoints.
Where to look next
README.md— features, layout, getting startedROADMAP.md— current phase of the layered grid schedulingPSEUDOVISION_INTEGRATION.md— why this repo talks directly to PV instead of going through Jellyfinreferences/tunarr-scheduler-scheduling-redesign.md— the batch→cron pipeline (commitba6f7c0)references/tunarr-scheduler-july-2026-changes.md— 14-day commit digest (always re-rungit log upstream/master --since="14 days ago"for current)references/catalog-aggregate-channel-filter-bug.md— Issue 4 (PV-side)references/handoff-tunarr-pseudovision.md(in tunabrain repo) — the authoritative spec for the scheduler↔tunabrain contract