The ChicoryTV React SPA (web/, served at /app) now has full parity for every route the Blazor UI served, so the legacy Blazor Server / MudBlazor UI is deleted. This is the milestone-capping removal of #91 phase (b). Deleted: ErsatzTV/Pages/**, Shared/**, ViewModels/** (39 edit VMs), Validators/** (10 edit-VM validators), App.razor, _Imports.razor, Locals/{Shared,Pages}/** (Blazor loc resx; Locals/Resources.* kept), wwwroot/css + wwwroot/lib, libman.json, and the orphaned MultiSelectBaseTests. Startup.cs (surgical, not wholesale): removed AddRazorPages/AuthorizeFolder, AddServerSideBlazor, AddMudServices, AddSortable, AddCourier, the HtmlSanitizer registration, the Blazor-attached OIDC UseAuthentication/UseAuthorization middleware (per the #206 auth-posture sign-off), MapBlazorHub, and MapFallbackToPage("/_Host"). Renamed the branch blazor->legacy; it still co-hosts MapControllers, /docs (Scalar), dev MapOpenApi and the redirect middleware. Replaced the _Host fallback with a catch-all (MapFallback -> 302 /app) that excludes /api|/artwork|/docs|/openapi (genuine 404) per #204. Kept all OIDC/JWT/API-key service wiring (inert unless configured; real auth is #197), ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter. Pruned 9 now-unused packages (all verified zero remaining consumers) from Directory.Packages.props + ErsatzTV.csproj: MudBlazor, Heron.MudCalendar, Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection, Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension. Also removed the now-dead #25 razor-Sonar NoWarn. LegacyUiRedirects: added the 14 /media/sources/* -> /app/libraries/* redirects (SPA screens landed in #202) and lifted the #204-era /media/sources prefix ban. Tests: Release build clean; full solution suite green. Updated Startup source-text tests + added regression coverage that Blazor wiring is gone, the catch-all is wired, and all 14 media-sources routes redirect. Docs: blazor-route-parity.md (phase b COMPLETE), decisions.md (removal entry), CLAUDE.md, contributing.md, README.md all updated in this PR. Rollback: tag blazor-final is cut on pre-merge main as the first merge action. Part of #91. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.5 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
- Language: C# / .NET 10
- 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 - Media: FFmpeg via CliWrap, SkiaSharp for logo generation
- Functional C#: Language Ext (Option, Either monads throughout)
Project Layout
| Project | Role |
|---|---|
ErsatzTV/ |
ASP.NET Core host, API controllers, SPA static hosting, DI setup |
web/ |
ChicoryTV React SPA (Vite + TypeScript; builds into ErsatzTV/wwwroot/app) |
ErsatzTV.Application/ |
MediatR handlers (business logic) |
ErsatzTV.Core/ |
Domain entities, interfaces, no infrastructure deps |
ErsatzTV.Infrastructure/ |
EF Core repos, data access |
ErsatzTV.Infrastructure.Sqlite/ |
SQLite-specific implementations |
ErsatzTV.FFmpeg/ |
FFmpeg process wrapper |
ErsatzTV.Scanner/ |
Media library scanning |
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.99), container
ersatztv, port 8409 - 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 deploys via Komodo GitOps: themedia-serverscompose intimothy/server-management(docker/bumblebee/stacks/media-servers/compose.yaml) pins the version tag (currently26.5.0, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks:latest. Pipeline details:docs/ci-cd.md.
Development
# Build
dotnet build ErsatzTV.sln
# Run locally (needs FFmpeg in PATH)
dotnet run --project ErsatzTV
# 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
docs/README.md(index) → the convention docs (api-conventions,spa-conventions,e2e-local,domain-model,blazor-route-parity,decisions). 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. -
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 docs/decisions.md(append-only) + 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. 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
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.
- Root cause (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
- Comment on issues as you work — what you found, what approach you're taking, any deviations from the suggested fix.
- Push changes:
git pushall commits before closing. Usefixes #Nin commit messages to auto-close where appropriate. - Close comment: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
- Close the issue via API or
fixes #Ncommit. Leave open with a comment only if partially addressed. - Update docs: If the change affects operational behavior, update the relevant Obsidian docs (
~/homelab-docs/), MEMORY.md, or CLAUDE.md inline — not as a follow-up. - Reply to reviewer (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
Project Boundaries
ersatztv OWNS: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
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 (symlinked)
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).