Files
ersatztv/CLAUDE.md
timothyandClaude Opus 5 f822e4737c
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 34s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 40s
Review verdict / Set review-verdict status (pull_request_target) Successful in 8s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 24s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m52s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
review-verdict/h10 Review-verdict: MERGEABLE @ f822e47 (base: main)
docs(743): label the second attested claim, close the survivor list, state the rule in CLAUDE.md
Round 3 returned MERGEABLE with three LOW documentation findings. Batched
before posting the verdict, since a new sha voids both the CI run and the
verdict.

- `ci-cd.md` labelled the unprobed half of the `enable_push` bullet but stated
  the `block_admin_merge_override` counterfactual flatly one bullet below —
  the same measured-vs-attested flattening round 2 fixed, one site over. Now
  labelled, with why it was not probed (verifying it means merging an
  unreviewed PR).
- `release.verdict-status-check` said "what survives is the forgery list
  above". That record's job is enumerating survivors, so an unqualified "what
  survives is X" reads as exhaustive — and it omitted the admin residual, which
  is a SKIP route rather than a forgery one. Added.
- `CLAUDE.md` never learned the rule. It is the always-read surface, and it
  still framed a direct `git push origin main` as a live path while describing
  a docs-only *push* exemption for a push the server now refuses. My corpus
  sweep covered `docs/` and missed the file that carries the docs-update rule.

Note on what remains unverified rather than closed: neither direction of
`block_admin_merge_override` was measured, and whether Gitea treats an ABSENT
required context as blocking (versus satisfied) is asserted by our docs but
not proven — the combined status on this PR reads `success` with
`review-verdict/h10` absent. Both belong to #747's re-verification sweep.

Verification: 441/441 script tests; decisions-validate OK.

refs #743

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:35:41 +02:00

15 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 in ErsatzTV/LegacyUiRedirects.cs or the Startup catch-all fallback (any unmatched non-/api//artwork//docs//openapi path → /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.csToM3U()
  • 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: jazz was 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 → /config in container
  • SQLite DB: /config/ersatztv.sqlite3 (WAL mode, root-owned)
  • Images (our fork, built by .gitea/workflows/docker-build.yml192.168.1.95:3000/timothy/ersatztv): push to main:latest + :<sha> (test image); push v* tag → :prod + :<version> + :<sha>. Prod's Komodo GitOps stack — named jazz-media (the compose project is still media-servers; a dead media-servers stack lingers on bumblebee) — follows floating :prod; after the immutable :<version> candidate passes the release scans, manually DeployStack 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.md before 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.md task-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; see docs/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.md
    Add / change a /api/* endpoint docs/api-conventions.md checklist, then regenerate v1.json + endpoint-index.md via ./scripts/update-openapi.sh
    Change a SPA screen convention docs/spa-conventions.md
    Establish / reverse a convention or decision a new docs/decisions/records/<area>/<topic>.md (filename = key; lifecycle: add record, git mv predecessor to archive/<area>/) + regenerate the catalog + the affected doc
    Add / remove / retitle a doc docs/README.md index

    The docs-reminder CI job flags a screen/route change that skips blazor-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 in ErsatzTV.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 *.Tests projects); 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 put Version= back on a <PackageReference> (trips NU1008). See docs/ci-cd.md → Dependency management.

  • DB migrations target BOTH providers: a TvContext model change needs a migration in ErsatzTV.Infrastructure.Sqlite and ErsatzTV.Infrastructure.MySql — run scripts/add-migration.sh <Name> (does both). CI's migrations job enforces model-drift + apply-to-fresh-DB per provider. See docs/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 once Build & test passes, the rest are manual. Their review-verdict/h10 required 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 .csproj or a source file is not blocked, it just needs a real verdict. Cross-repo rollout: server-management#484. See docs/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.0 is 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 (emits permissionDecision: allow, so no redundant mechanical prompt fires) only when the PR's CI is green and every ## Done-when box on the linked issue (fixes #N) is ticked and a Review-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-when section, no Review-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), run scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note] — it posts both the Review-verdict: … @ <head-sha> comment and the sha-bound review-verdict/h10 commit 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 gate is enforced server-side, per sha (ersatztv#622). review-verdict/h10 is a required status check on main. 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 where merge_when_checks_succeed froze 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/ or docker/ci/. See docs/ci-cd.md → Review-verdict gate.
  • .husky/pre-pushprepush-donewhen.sh — a fail-open backstop that blocks a direct git push origin main whose commits fix #N an 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: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, and the ersatztv skill — whose canonical copy is .claude/skills/ersatztv/SKILL.md here; ~/server-management/.claude/skills/ersatztv is a symlink to it (ersatztv#617). Edit it in this repo; never fork a second copy.

ersatztv does NOT own:

  • 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 (planned)
  • Jellyfin skill → server-management. .claude/skills/jellyfin here 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 (what goes into channels, yt-dlp pipelines): open an issue in timothy/media-management once it exists; for now, timothy/server-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).