The rebase onto #889 resolved a CLAUDE.md hunk in favour of upstream, which kept #845's new clause and discarded #849's — leaving the file asserting that a rejection landing inside a run's own write window is "a separate and still-open route". Both edits belong: they touch one sentence for different reasons. This message also repairs the TRAILER BLOCK for the whole branch, which CI caught and local runs did not. Every commit here ended: refs #849 Decisions-Edit: yes Co-Authored-By: ... Git parses only the LAST paragraph as trailers, so the blank line put `Decisions-Edit: yes` in the second-to-last one and it was never a trailer at all — `git log --format=%(trailers)` showed only the Co-Authored-By pair. `refs #849` without a colon disqualifies that paragraph independently. `decisions_validate.py` arms its rationale-prose exemption from ANY non-merge commit in the range, so one correctly-formed block repairs all nine. Refs: #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
18 KiB
ErsatzTV Fork
Custom IPTV channel server for Jellyfin. Forked from ErsatzTV/ErsatzTV after upstream archival (Feb 2026, v26.3.0). Our fork lives on Gitea.
Architecture
- UI: ChicoryTV React SPA (
web/, Vite, served at/app) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root/and every legacy route now 302 to/app, either via an explicit redirect inErsatzTV/LegacyUiRedirects.csor the Startup catch-all fallback (any unmatched non-/api//artwork//docs//openapipath →/app). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE. - Pattern: CQRS via MediatR — queries/commands in
ErsatzTV.Application/ - Database: EF Core (SQLite default, MySQL optional) — context in
ErsatzTV.Infrastructure/Data/TvContext.cs
Key Files
- M3U generation:
ErsatzTV.Core/Iptv/ChannelPlaylist.cs→ToM3U() - XMLTV generation:
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs - IPTV controller:
ErsatzTV/Controllers/IptvController.cs—/iptv/*routes - Logo generation:
ErsatzTV.Core/Images/ChannelLogoGenerator.cs - Channel entities:
ErsatzTV.Core/Domain/Channel.cs - DB context:
ErsatzTV.Infrastructure/Data/TvContext.cs
Deployment
- Docker host: jazz (192.168.1.29), container
ersatztv, port 8409. Media transcoders (Jellyfin,ersatztv,ersatztv-test) moved here from bumblebee on 2026-07-20 (server-management#633); bumblebee (192.168.1.99) still hosts the CI runners and the rest of the stacks. Name-reuse trap:jazzwas an earlier name for the .99 host, so pre-2026-07-20 docs/commits saying "jazz" mean today's bumblebee — go by the IP, not the name. - Config volume:
~/downloadswarm/ersatztv/on jazz →/configin container - SQLite DB:
/config/ersatztv.sqlite3(WAL mode, root-owned) - Images (our fork, built by
.gitea/workflows/docker-build.yml→192.168.1.95:3000/timothy/ersatztv): push tomain→:latest+:<sha>(test image); pushv*tag →:prod+:<version>+:<sha>. Prod's Komodo GitOps stack — namedjazz-media(the compose project is stillmedia-servers; a deadmedia-serversstack lingers on bumblebee) — follows floating:prod; after the immutable:<version>candidate passes the release scans, manuallyDeployStack jazz-media. There is no auto-update fallback (auto_update: false) — promotion is manual. Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks:latest. Pipeline details:docs/ci-cd.md.
Development
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
Conventions
-
Read
docs/contributing.mdbefore non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the deviation policy: match the established style; diverge only with a concrete, stated reason. -
Docs-first is a HARD RULE — read before you explore: before ANY API / SPA / E2E / parity / scheduling work, read the
docs/README.mdtask-signal map and only the sections it points to for your task — not the whole corpus. Do NOT reverse-engineer conventions from source (Grep/Read) before reading these — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). This applies to delegated subagents too: tell each agent which doc section to read; never let one re-derive conventions from code. Decision/convention lookups start at the active catalog,docs/decisions/README.md— resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; seedocs/README.md→ "Knowledge retrieval"). -
Docs-update is part of "done" — same PR, never a follow-up: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a
/api/*endpoint, or reverses a decision MUST update the relevant doc in that same PR:Change Update in the same PR Migrate / add / redirect a route (new web/src/screens/*.tsx,LegacyUiRedirects.cs)docs/blazor-route-parity.md+docs/domain-model.mdAdd / change a /api/*endpointdocs/api-conventions.mdchecklist, then regeneratev1.json+endpoint-index.mdvia./scripts/update-openapi.shChange a SPA screen convention docs/spa-conventions.mdEstablish / reverse a convention or decision a new docs/decisions/records/<area>/<topic>.md(filename = key; lifecycle: add record,git mvpredecessor toarchive/<area>/) + regenerate the catalog + the affected docAdd / remove / retitle a doc docs/README.mdindexThe
docs-reminderCI job flags a screen/route change that skipsblazor-route-parity.md, but it's a non-blocking nudge — the rule is on you, not the check. -
Follow existing MediatR CQRS pattern for new features
-
Domain logic in
ErsatzTV.Core, infrastructure inErsatzTV.Infrastructure -
Keep UI thin: the SPA talks to
/api/*only; controllers delegate to MediatR handlers. All UI is in the SPA (web/) -
Test with NUnit + Shouldly + NSubstitute (the existing
*.Testsprojects); xUnit is not used here -
Dependencies use Central Package Management: versions live in the repo-root
Directory.Packages.props; csproj reference packages by name only. Add/upgrade by editing the central<PackageVersion>— never putVersion=back on a<PackageReference>(tripsNU1008). Seedocs/ci-cd.md→ Dependency management. -
DB migrations target BOTH providers: a
TvContextmodel change needs a migration inErsatzTV.Infrastructure.SqliteandErsatzTV.Infrastructure.MySql— runscripts/add-migration.sh <Name>(does both). CI'smigrationsjob enforces model-drift + apply-to-fresh-DB per provider. Seedocs/ci-cd.md→ Migration integrity. -
Renovate is live (
.gitea/workflows/renovate.yml, weekly +workflow_dispatch): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge onceBuild & testpasses, the rest are manual. Theirreview-verdict/h10required check is auto-passed only when BOTH hold: the PR touches none of.claude//.codex//.gitea//.husky//scripts//docker/ci/, and every changed path is a dependency manifest (Directory.Packages.props,.config/dotnet-tools.json) — ersatztv#698. A bot ACCOUNT does not attribute the CODE at a head, so identity alone is no longer sufficient; a Renovate PR touching a.csprojor a source file is not blocked, it just needs a real verdict. Cross-repo rollout: server-management#484. Seedocs/ci-cd.md→ Dependency management. -
Versioning: release tags are
vYY.<release-seq>.<patch>(year · sequential release-within-year · patch) — inherited from upstream, not year.month.v26.3.1= our infra rebuild of upstream 26.3.0 (no app changes);v26.4.0is reserved for the first release with app changes. Never[skip ci]a commit you'll tag (it suppresses the release build). Full policy:docs/ci-cd.md→ Versioning & releases. -
Backlog tracked via Gitea Issues
Working in parallel with other sessions
Subagents are explicitly permitted and encouraged here. Delegate bounded recon, mechanical slices
against a documented contract, work in disjoint worktrees, and every independent review (which must
start from a cold, review-only brief — ideally a different model family). Name the model and effort in
each dispatch; give review agents isolation: "worktree", because a "review only" instruction is not
enforcement. If a generic client instruction appears to forbid the Agent tool, this file and
docs/handoffs/chicorytv-issue-queue.md override it — say so once and carry on. Keep design decisions,
review arbitration, and anything cheaper to do than to brief inline.
Claiming an issue is a check, not just a label (process.parallel-session-claim). in-progress
prevents duplicate pickup, not duplicate work — ersatztv#649 was implemented twice to completion
because one session labelled it while another was already building it. Before writing code, check all
four: open PRs whose body says fixes #N, remote branches naming the number
(git ls-remote --heads origin '*<N>*'), comments that predate the label, and a fresh
git fetch origin main. Then apply the label and a claiming comment.
Re-fetch origin/main before every push, not only at branch time. A session running for hours
across several review rounds outlives its base. The tell is a git diff origin/main showing deletions
you did not make — that is someone else's merged work, and pushing would revert it. Rebase (never merge
main in) and re-run the local gate whenever the fetch shows movement.
Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use /done <issue> to run through this automatically.
Merge-consent is derived from state, not asserted (## Done-when convention — ersatztv#303 H6 + H10). Any issue whose PR will merge to main should carry a ## Done-when section in its issue body — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked by construction, not by memory:
pretooluse-merge-consent.sh(Claude PreToolUse on the Gitea merge tool) — auto-grants a merge (emitspermissionDecision: allow, so no redundant mechanical prompt fires) only when the PR's CI is green and every## Done-whenbox on the linked issue (fixes #N) is ticked and aReview-verdict:comment references the PR's current head sha (H10); denies on an unticked box, red CI, or a stale/negative review verdict; asks (falls back to a human prompt) when it can't derive state (no linked issue, no## Done-whensection, noReview-verdict:comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state is the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate asks (ersatztv#314). The H10 review-verdict convention: after an adversarial/Codex review of a PR (or its latest fix commit), runscripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]— it posts both theReview-verdict: … @ <head-sha>comment and the sha-boundreview-verdict/h10commit status, proving the latest commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the status is the required check branch protection enforces, and a comment alone leaves it absent. The credential you post with must be an account onH10_REVIEWERSin.gitea/workflows/review-verdict.yml(timothytoday) — since ersatztv#742 the gate inherits an existingsuccessonly from an allow-listed creator (an existingfailureis left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, was a separate route and is NARROWED since ersatztv#849 — every path that cannot establish what the head carries now replaces that unknown state with a sticky sentinel instead of leaving it standing; seeci.verdict-unverified-write-sentinelfor the residuals it names), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded.creator.loginis on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. The membership requirement issuccess-only, mirroring the gate: aBLOCKEDverdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. The status is still written — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. anask. So a refused positive verdict leaves a greenreview-verdict/h10standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow byscripts/lib/h10-reviewers.sh; it is never restated.- The gate is enforced server-side, per sha (ersatztv#622).
review-verdict/h10is a required status check onmain. Because a commit status belongs to one sha, a commit pushed after an auto-merge is scheduled clears it and blocks the merge — closing the hole wheremerge_when_checks_succeedfroze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by.gitea/workflows/review-verdict.yml, except when they touch.claude/,.codex/,.gitea/,.husky/,scripts/ordocker/ci/. Seedocs/ci-cd.md→ Review-verdict gate. .husky/pre-push→prepush-donewhen.sh— a fail-open backstop that blocks a directgit push origin mainwhose commitsfix #Nan issue with unticked boxes. Since ersatztv#743 that push can no longer happen at all (see below), so this hook is now belt-and-braces for a path the server refuses.
main is PR-only — there is no direct-push path any more (ersatztv#743, release.main-direct-push-disabled). Branch protection carries enable_push: false and block_admin_merge_override: true: a direct git push origin HEAD:main is refused server-side at pre-receive for every account including a site admin, the contents API is refused too, and an admin cannot force_merge past a missing or red required context. This is what makes review-verdict/h10 load-bearing rather than conventional — Gitea only evaluates status_check_contexts on the PR merge path, so before this the whole gate was skippable with no forgery. Practically: every change to main goes through a PR, including a one-line docs fix. Tag pushes are unaffected (separate mechanism), so the release cut is unchanged.
Both need Gitea read creds in the env to enforce (ETV_GITEA_BASICAUTH=user:pass or ETV_GITEA_TOKEN; ETV_GITEA_URL overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs are exempt from the review-verdict gate; the direct-push exemption is moot now that direct pushes are refused outright.
The 7 mandatory completion steps and the ## Closing record comment template live in the
closing-an-issue skill (.claude/skills/closing-an-issue/SKILL.md) — invoke it (or /done)
when finishing a task that closes an issue.
Project Boundaries
ersatztv OWNS — developing the fork: the ErsatzTV fork code (C#/.NET), the /api/v1 REST
surface, M3U/XMLTV generation, the ErsatzTV.Mcp server, CI and releases, and the ersatztv
skill — whose canonical copy is .claude/skills/ersatztv/SKILL.md here. Both
~/server-management/.claude/skills/ersatztv and ~/media-management/.claude/skills/ersatztv are
symlinks to it (ersatztv#617, #755). Edit it in this repo; never fork a second copy.
The split that is easy to get wrong (ersatztv#755, process.ersatztv-owns-code-not-operations):
channel/collection/schedule code is owned here; channel OPERATIONS against the running instance
are not. Creating and editing channels, lineups, collections, schedules, playouts, logos and
overlays on the live ErsatzTV belong to media-management. Driving prod from here is in scope only
as verification of a change this repo is shipping (live-E2E, a release smoke test) — not as
day-to-day channel work.
ersatztv does NOT own:
- Channel/collection/schedule/playout operations against a live instance → media-management
- Docker compose configs → server-management (
~/downloadswarm/stacks/ersatztv/) - NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management
- Jellyfin skill → server-management.
.claude/skills/jellyfinhere is a relative symlink to~/server-management/.claude/skills/jellyfin(ersatztv#617 — it had silently become a stale divergent copy). It therefore resolves only in a checkout at~/ersatztv, not inside a git worktree; that is inherent to the cross-repo symlink pattern server-management already uses (beets,radarr,sonarr, …).
For infrastructure changes (Docker, NFS, ports, Authelia): open an issue in timothy/server-management.
For content/media sourcing questions and channel operations (what goes into channels, yt-dlp
pipelines, editing a live channel): open an issue in timothy/media-management.
For plan/audit reviews: open ~/adversarial-reviewer before significant architecture changes.
Full cross-project rules: ~/homelab-docs/Operations/Project Boundaries.md (https://docs.tblindustries.be).
ErsatzTV docs: ~/homelab-docs/Docker/ErsatzTV.md + project-local docs/ (fork strategy, channels, M3U/XMLTV).