Files
ersatztv/docs/contributing.md
T
6052626316 chore: widen the BOM-guard remedy to staged + dirty files (review nit)
Cold review (MERGEABLE-WITH-NITS) flagged that the printed fix snippet and the
docs by-hand check used `git diff origin/main...HEAD`, which excludes
staged-but-uncommitted files. A brand-new staged .cs is exactly what fires the
pre-commit deny yet is absent from that diff, so the copy-paste remedy would strip
nothing → deny again → loop. (The deny still names the file, so manual stripping
always worked; this just makes the printed remedy match the guard's own detection
set: branch diff ∪ staged ∪ dirty.)

Verified on a fresh fixture: a brand-new staged BOM file is denied, and the
widened snippet strips it.

Left as accepted (fail-open, Medium, CI backstops; shared with sibling hooks):
`git -C <path>` isn't parsed for the target tree (only `cd` is — matches the
`cd <wt> && git` usage here), and the heredoc-body false-deny vector that
bash-guard/worktree-guard also carry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:50:01 +00:00

11 KiB

Contributing to the ErsatzTV fork

A descriptive guide to the conventions already in this codebase — written so future changes (human or AI) extend the established architecture instead of reinventing it. It's a living document: update it when a pattern legitimately evolves.

The guiding principle

Match the established style and patterns. Diverge only when there's a genuinely strong reason — when it's the real best or only option — even if that means rearchitecting. Never deviate gratuitously or just because a different approach is locally convenient. When a deviation is justified, state why (in the PR/commit, and in code comments where it'll surprise a reader).

Several of the rules below are now enforced in CI (layering tests, analyzers, formatting, migration checks) — see the pointers per section.

1. Architecture & layering

CQRS via MediatR. Dependency direction is enforced by ErsatzTV.Architecture.Tests (NetArchTest):

Project Responsibility May depend on
ErsatzTV.FFmpeg ffmpeg process/pipeline wrapper (nothing — lowest layer)
ErsatzTV.Core domain entities, interfaces, pure logic FFmpeg only — no EF Core, no Infrastructure/Application
ErsatzTV.Infrastructure EF Core data access, external clients Core
ErsatzTV.Infrastructure.Sqlite / .MySql provider-specific EF (migrations) Core, Infrastructure
ErsatzTV.Application MediatR handlers (business logic) Core, Infrastructure (the abstraction, not the concrete providers)
ErsatzTV.Scanner library scanning host Core, Infrastructure*
ErsatzTV ASP.NET Core host, controllers, DI composition root everything

If a test fails here, the fix is almost always to move the type, not to relax the rule. (ersatztv#12)

2. CQRS handler conventions

  • Requests are record types named for the action — GetX / CreateX / UpdateX / DeleteX — implementing IRequest<TResult>, living under <Domain>/Queries/ or <Domain>/Commands/ next to their handler.
  • Result types (LanguageExt, see §3): queries return Option<T> or List<T>; commands return Option<BaseError> (void success) or Either<BaseError, T> (value success).
  • Handlers implement IRequestHandler<TRequest, TResult> with primary-constructor DI and an async Task<TResult> Handle(TRequest request, CancellationToken cancellationToken).
  • Validation lives in the handler as private static methods returning Validation<BaseError, T>, composed with tuple .Apply(...)not FluentValidation or MediatR pipeline behaviors.
  • Examples: ErsatzTV.Application/Channels/Queries/GetChannelById.cs (+ …/GetChannelByIdHandler.cs, Option<ChannelViewModel>), ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs (tuple-Apply validation → Either<BaseError, Unit>).

3. Functional style (LanguageExt)

Every project globally imports using static LanguageExt.Prelude;. Use the monads instead of exceptions for control flow.

  • Option<T>Optional(x), Some/None, HeadOrNone() to pluck from a collection; branch with Match/MatchAsync(onSome, onNone); default with IfNone(...); transform with Map/Bind; foreach (var x in option) to unpack conditionally.
  • Either<BaseError, T> / Validation<BaseError, T> — the error backbone. BaseError.New("…") for failures, Unit.Default for void success. Accumulate multiple checks with Validation (tuple .Apply((a, b, c) => …)) then ToEither(). MapLeft to transform errors.
  • Examples: ErsatzTV.Core/FFmpeg/…, ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs.

4. Web UI (ChicoryTV SPA)

The legacy Blazor Server UI + MudBlazor were removed in #91 phase (b). The ChicoryTV React SPA in web/ (Vite + TypeScript, built into ErsatzTV/wwwroot/app, served at /app) is the only UI; it talks exclusively to the REST API. Every former Blazor route now 302-redirects to its /app equivalent (ErsatzTV/LegacyUiRedirects.cs + the Startup catch-all).

  • Adding a screen: see docs/spa-conventions.md for the SPA screen playbook.
  • Endpoints the screen calls: see docs/api-conventions.md for the endpoint checklist.
  • Keep the UI thin — screens call /api/*; business logic stays in MediatR handlers.

5. Data access & EF Core

  • TvContext (ErsatzTV.Infrastructure/Data/TvContext.cs) is the single DbContext; resolve it via IDbContextFactory<TvContext> in handlers.
  • Two providers: ErsatzTV.Infrastructure.Sqlite (the prod default, /config/ersatztv.sqlite3) and …MySql, each with its own Migrations/ set + TvContextModelSnapshot. The provider is chosen by the provider config key in Startup.
  • A model change needs a migration in BOTH providers — run scripts/add-migration.sh <Name>. CI's migrations job enforces model-drift + apply-to-fresh-DB per provider (see docs/ci-cd.md → Migration integrity). Review non-transactional ops (e.g. SQLite PRAGMA foreign_keys) carefully. (ersatztv#13)

6. FFmpeg pipeline

  • Step-based composition: each task is an IPipelineStep (ErsatzTV.FFmpeg/IPipelineStep.cs) that contributes GlobalOptions/InputOptions/FilterOptions/OutputOptions and a NextState() that threads frame metadata (pixel format, data location, dimensions). CommandGenerator.GenerateArguments assembles the steps into the final argument list.
  • Detect capabilities at runtime; don't hardcodeIHardwareCapabilities implementations (Vaapi/Nvidia/… HardwareCapabilities) report available codecs/profiles, and builders pick the encoder/decoder accordingly.
  • Version-gate features explicitly — e.g. Capabilities/NvidiaHardwareCapabilities.cs parses FFmpegCapabilities.Version and disables 10-bit H.264 decode hwaccel below ffmpeg 8. Follow this pattern for the ffmpeg 8 upgrade (ersatztv#9).
  • Examples: ErsatzTV.FFmpeg/Encoder/EncoderBase.cs, ErsatzTV.FFmpeg/Filter/ScaleFilter.cs.

7. Naming, formatting, analyzers

  • .editorconfig is the source of truth for formatting + rule severities (plus ErsatzTV.sln.DotSettings for ReSharper). Run dotnet format before committing.
  • Fix formatting as you touch it (no big-bang). ~2500 legacy .cs files inherited from upstream carry a UTF-8 BOM, which violates .editorconfig's charset=utf-8. We do not mass-reformat. Instead, when you modify a file for other work, normalize it in that same PRdotnet format ErsatzTV.sln --include <the files you touched>, which strips the BOM and fixes style. Files you did not touch stay as-is. This is enforced three ways so it can't be silently skipped (ersatztv#311): the pre-commit hook verifies staged .cs; a blocking format CI job re-verifies the .cs this PR changed against .editorconfig (a .cs-free PR passes trivially); and a Claude PreToolUse guard (.claude/hooks/pretooluse-bom-guard.sh) refuses a git commit/git push whose touched .cs still carry a BOM. The guard exists because the first two are skippable in practice: worktree hook friction means --no-verify is routine, which bypasses the pre-commit check, leaving CI — a ~10-minute round trip — as the first thing that tells you. It fires before git runs, so --no-verify can't skip it, and it names the files plus the fix. Fail-open by design; generated *.Designer.cs / TvContextModelSnapshot.cs are exempt, matching what dotnet format skips. Never --no-verify past a format failure on a file you changed — de-BOM/format it instead. Watch for the re-add: an edit that rewrites a legacy file usually preserves its BOM — Python io.open(..., encoding='utf-8-sig') writes one back, and sed/perl round-trips keep it. This cost two sessions a red CI job on 2026-07-17 alone (PR #405, 6 files; PR #402, 19), which is what the guard is for. To check by hand: for f in $(git diff --name-only origin/main...HEAD --cached -- '*.cs'; git diff --name-only -- '*.cs') ; do head -c3 "$f" | xxd -p | grep -q '^efbbbf' && echo "BOM: $f"; done (covers branch + staged + dirty — a brand-new staged file is absent from origin/main...HEAD) (note dotnet format --include needs bashmapfile is bash-only, and under zsh the file list comes out empty, which looks exactly like the tool silently doing nothing). (A one-time repo-wide normalization is a separate, unmade decision; the touched-file rule is the standing one.)
  • TreatWarningsAsErrors=true in the app projects — a warning fails the build. NoWarn carries a small, documented exemption list (e.g. VSTHRD200, CA1873); NuGet-audit NU1901-1903 are demoted to warnings in Directory.Build.props while NU1904 (critical) blocks.
  • Static analysis is centralized: Directory.Build.props enables the SDK analyzers at latest-All plus the threading analyzer for every centrally managed project; Directory.Build.targets adds Roslynator, SonarAnalyzer, Meziantou, and AsyncFixer. Analyzer package references are CPM-guarded so the gitignored .mcp tool retains its inline-version dependency model. The SDK globalconfig and .editorconfig keep the broad baseline at suggestion. Promote one reviewed rule at a time by setting it to warning and adding its ID to the central WarningsAsErrors list; never flip a wall of rules to error at once. Refresh the SDK globalconfig deliberately when moving to a new .NET SDK major. (ersatztv#15)

8. Testing

NUnit ([TestFixture]/[Test]/[TestCase]) + Shouldly + NSubstitute; xUnit is not used. Golden-file tests guard the M3U/XMLTV output formats and a hard fail on a missing/changed golden means broken code, not a stale fixture — never regenerate via ETV_UPDATE_GOLDENS=1 in CI or from an agent. Full testing map (per-project coverage, golden-file details, timezone notes, how to run subsets, the per-PR gate): docs/testing.md.

9. Build / CI

  • Central Package Management: versions live in the repo-root Directory.Packages.props; csproj reference packages by name only — never put Version= back on a <PackageReference> (NU1008). Repo-wide MSBuild config is in Directory.Build.props / Directory.Build.targets. (ersatztv#14)
  • The Gitea Actions pipeline (.gitea/workflows/docker-build.yml): testmigrationsbuild (image + smoke/IPTV-E2E). Renovate opens dependency PRs; a weekly dependency-scan surfaces advisories. Full details: docs/ci-cd.md.
  • Versioning is CalVer vYY.<release-seq>.<patch> (not year.month); never [skip ci] a commit you'll tag. See docs/ci-cd.md → Versioning.

10. Deviation policy

Re-stating §0 because it's the whole point: follow the established pattern; diverge only with a concrete, stated reason. Record an intentional departure in the PR description and a code comment at the point of surprise (e.g. "using X instead of the usual Y because …"). If a pattern itself should change, change it deliberately and update this guide in the same PR.