# 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 hardcode** — `IHardwareCapabilities` 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 PR** — `dotnet 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 **bash** — `mapfile` 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`): `test` → `migrations` →
  `build` (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.
