feat(521): migrate decisions.md range B: #385 through #502 decision records to lifecycle schema [decisions-edit]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 11:29:05 +02:00
co-authored by Claude Opus 4.8
parent ab2d80b47a
commit 2e34d9c47b
+92
View File
@@ -1610,6 +1610,10 @@ rule a hook, not prose to remember" throughline. Verified: dirty PR-file → blo
dirty non-PR file → allow; escape hatch → allow.
## 2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)
`key: sched.autotune-per-channel-overrides` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425.
**Signals:** Auto-Tune, per-channel overrides, advanced-options DTO reuse · paths: `CreateAutoTunedChannelsHandler`, `POST /api/v1/channels/auto-tune` · issues: #385, #383, #425, #283
**Mechanics:** `CreateAutoTunedChannelsHandler.CreateOne`; `CreateChannelFromLineupAdvancedOptionsRequest`
The Auto-Tune DetailPanel (#383) makes each proposed channel individually editable before bulk-create.
The backend for that (#385) split cleanly along a "structural cost" line, and only the additive half
@@ -1654,6 +1658,10 @@ shipped here; the rest is deliberately deferred rather than forced.
`logo` above already covers the on-screen bug for channels that supply an image.
## 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)
`key: api.healthcheck-remediation-dto` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself.
**Signals:** health checks, remediation, AppRoute/ExternalDoc · paths: `HealthCheckResponseModel`, `HealthCheckLink` · issues: #164, #286, #108
**Mechanics:** `HealthCheckResponseModel.Remediation`; Application `Mapper.GetStatus`
#164 asked to make the ~14 health checks *actionable* — the Dashboard health panel showed problems
with no way to investigate or fix them. Two structural decisions came out of it.
@@ -1697,6 +1705,10 @@ code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets
polls.
## 2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)
`key: spa.autotune-detailpanel-slideover` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** The Auto-Tune DetailPanel SPA is a reusable `SlideOver` primitive sharing `useOverlayBehavior` with `Dialog`, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped.
**Signals:** Auto-Tune DetailPanel, SlideOver, shared advanced-options hook · paths: `web/src/components/overlay.tsx`, `web/src/builder/advancedOptions.tsx` · issues: #386, #384, #385, #425, #135
**Mechanics:** spa-conventions.md §11; `useAdvancedOverrides` hook
The SPA half of the Auto-Tune per-channel editor. It builds only what the shipped `/api/v1` surface
(#384 members read, #385 per-channel `templateId`/`logo`/`advanced`) can actually carry, so the panel
@@ -1727,6 +1739,10 @@ never presents a control with nowhere to send its value.
makes that visible) and only screen navigation / full-page unload with uncommitted edits confirms.
## 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)
`key: spa.smartcollection-rule-builder` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
**Signals:** SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69
**Mechanics:** `GET /api/v1/search/fields`; api-conventions.md; spa-conventions.md §12
The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
@@ -1763,6 +1779,10 @@ for value inputs, relative-date operators, nesting deeper than one level, and in
`spa-conventions.md` §12).
## 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)
`key: sched.autotune-per-source-weights` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT.
**Signals:** Auto-Tune, per-source weights, MultiCollection, WeightedShuffle · paths: `AutoTunedChannelRequest`, `OwnedByChannelId` migration · issues: #425, #70, #383, #386, #385
**Mechanics:** `AddCollectionOwnedByChannelId` migration; `WeightedShuffleCollectionEnumerator`
Per-source rotation weights (`3× Show A, 1× Show B`) and query corrections (exclude / add-untagged) for
an auto-tune channel are supplied **at bulk-create time** — an optional `sources: [{sourceId, weight,
@@ -1813,6 +1833,10 @@ non-atomic across the two handlers (mirrors #69) with best-effort rollback of th
failure.
## 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
`key: api.search-allitems-paging` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response.
**Signals:** search all-items, pagination, DoS hardening · paths: `SearchController.SearchAllItems`, `LuceneSearchIndex`, `web/src/api/search.ts` · issues: #293, #285, #308, #384
**Mechanics:** `MaxAllItemsPageSize`/`DefaultAllItemsPageSize` clamps; `getAllSearchItemIds`
`GET /api/v1/search/all-items` (`SearchController.SearchAllItems` → `QuerySearchIndexAllItemsHandler`) fired
ten index searches with **`limit: 0`** (= "return every hit", `LuceneSearchIndex` line ~244), so a single
@@ -1855,6 +1879,10 @@ page to completeness** — rather than option (b) (a generous cap + truncation s
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)
`key: spa.sidebar-collapsible-accordions` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed.
**Signals:** sidebar, collapsible nav groups, localStorage keys · paths: `web/src/app/AppShell.tsx`, `web/src/app/sidebarState.ts` · issues: #396
**Mechanics:** spa-conventions.md §5d; `ctv-sidebar-collapsed` / `ctv-sidebar-groups` keys
The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px
icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled
@@ -1885,6 +1913,10 @@ icon rail and (b) collapsible accordions per **labeled** nav group (Media, Syste
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
## 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
`key: sched.playbackorder-support-matrix` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction.
**Signals:** PlaybackOrder, support matrix, build-time dispatch · paths: `ErsatzTV.Core/Scheduling/PlaybackOrderSupport`, `PlaylistEnumerator.Create`, `BlockPlayoutBuilder` · issues: #403, #70
**Mechanics:** `PlaybackOrderSupportTests` (partition assertion)
`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
@@ -1931,6 +1963,10 @@ the three still-**silent** build-time dispatch sites — the ones this issue nam
*order* degrades silently" and is not part of #403's scope.
## 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip
`key: ci.build-once-rejected` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead.
**Signals:** CI build-once, artifact tar/transport cost, tree-identity skip · paths: `docs/ci-cd.md` → Cross-run tree-identity skip · issues: #420, #398, #455
**Mechanics:** docs/ci-cd.md → Cross-run tree-identity skip; PR #455 measurement
Build-once (a `compile` job producing a single artifact, consumed by `test`/`migrations`/
`functional-e2e` via `--no-build`) was fully implemented and went **green on CI** (PR #455, run
@@ -1952,6 +1988,10 @@ Build-once (a `compile` job producing a single artifact, consumed by `test`/`mig
Refs: #398 (closed), #420, PR #455.
## 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
`key: media.lastscan-null-boundary` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup.
**Signals:** LastScan, MinValue sentinel, API-boundary honesty · paths: `GetAllMediaSourcesForApiHandler.NormalizeLastScan`, `MediaSourceRepository` · issues: #409
**Mechanics:** `NullOutNeverScannedLastScan` migration; `LibrariesScreen.tsx` `hasScanned` removal
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel still appears in the
@@ -1988,6 +2028,10 @@ is honest. (Follow-up option, not done here: have `MediaSourceRepository` write
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
## 2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)
`key: sched.weightedshuffle-editor` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode.
**Signals:** WeightedShuffle SPA, multi-collection weights, fair-share reset · paths: `web/src/.../itemRules.ts` `MULTI_COLLECTION_ORDERS`, `itemsFromMultiCollection`/`toItemRequest` · issues: #404, #70, #402
**Mechanics:** spa-conventions.md §4 (replace-all-DTO trap); `fillWithGroupModeEligible` exclusion
The UI half of #70 (backend + API shipped in PR #402). No new endpoint or DTO — `weight` was already on
`MultiCollectionItemRequest`/`…ResponseModel` and `WeightedShuffle` already in the `PlaybackOrder` enum; this
@@ -2027,6 +2071,10 @@ is purely SPA (+ docs).
gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
## 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
`key: api.healthcheck-ttl-cache` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh.
**Signals:** health check caching, TTL, refresh query param · paths: `HealthCheckService._memoryCache`, api-conventions.md §1/§3b · issues: #431, #164
**Mechanics:** `PerformHealthChecks(forceRefresh, ...)`; `GET /api/v1/health?refresh=true`
`HealthCheckService.PerformHealthChecks` re-ran all 14 checks on **every** call, four of which shell out to
`ffmpeg`/`ffprobe` via CliWrap — so a bare `GET /api/v1/health` spawned ~4 subprocesses per request. The
@@ -2058,6 +2106,10 @@ performance half.
reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
## 2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)
`key: ci.format-gate-folder-mode` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage.
**Signals:** dotnet format, folder mode, CI format gate · paths: `.gitea/workflows` format job, `.editorconfig` · issues: #469, #406, #311
**Mechanics:** `dotnet format whitespace . --folder --verify-no-changes --include <files>`
The blocking `format` CI job (and the matching Husky pre-commit hook) verify changed `.cs` files with
`dotnet format whitespace . --folder --verify-no-changes --include <files>` instead of the previous
@@ -2128,6 +2180,10 @@ off the live test-job peak-anon this instrument now reports, not off the build-o
(swap/read-method move the figure >1 GiB).
## 2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)
`key: media.remote-stream-probe` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle.
**Signals:** remote-stream probing, fail-open/fail-closed, exit-8 ffmpeg loop · paths: `GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, `IRemoteStreamProber` · issues: #473, #350, #480
**Mechanics:** `HttpRemoteStreamProberTests` (`Should_Fail_Open_*` / `Should_Fail_Open_On_404_That_Was_Not_Redirected`)
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath` now probes the Plex/Jellyfin/Emby
remote-stream URL via the new `IRemoteStreamProber` seam before returning it, and on a 404 **from the media
@@ -2188,6 +2244,10 @@ server** returns the new `PlayoutItemNotAvailableFromMediaServer` error instead
untested one.
## 2026-07-19 — A media-server library sweep refuses to flag when a successful fetch returns zero items, rather than nuking the whole library (#477)
`key: scan.zero-item-fetch-guard` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion.
**Signals:** library sweep, zero-item guard, anti-nuke · paths: `MediaServerReconciliationGuard`, `MediaServerTelevisionLibraryScanner`/`MovieLibraryScanner`/`OtherVideoLibraryScanner` · issues: #477, #476
**Mechanics:** `MediaServerReconciliationGuardTests` policy table
Each media-server scanner reconciles "gone upstream" as `existing.Except(incoming)` and flags the result
`FileNotFound`. If a *successful* fetch returns **zero** items — the server is up but mid-restore /
@@ -2234,6 +2294,10 @@ protection by accident of control flow, not by design.
watching all four anti-nuke assertions fail while the `(0,0)` no-op case stays green.
## 2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)
`key: media.remote-stream-probe-externaljson` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows.
**Signals:** external-JSON channels, remote-stream probe, scope-gap follow-up · paths: `ExternalJsonPlayoutItemProvider.StreamRemotely` · issues: #480, #473
**Mechanics:** `ExternalJsonPlayoutItemProviderTests`
The #473 fix (PR #479, the entry above dated 2026-07-19) probed Plex/Jellyfin/Emby remote-stream URLs in
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, but explicitly scoped itself to the
@@ -2269,6 +2333,10 @@ for its whole slot. This closes that gap.
neutralizing the probe guard and watching the unavailable assertion flip to `Right`.
## 2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488)
`key: scan.getoraddfolder-db-lookup` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers.
**Signals:** GetOrAddFolder, LibraryFolders navigation, ArgumentNullException · paths: `ILibraryRepository.GetOrAddFolder`, `JellyfinMusicVideoLibraryScanner` · issues: #488
**Mechanics:** `LibraryRepositoryTests` (`LibraryPath.LibraryFolders == null` case)
`GetOrAddFolder` looked the existing folder up by reading `libraryPath.LibraryFolders` in memory. That
navigation collection is only eager-loaded on the **local** scan path — `LibraryRepository.GetLibrary`
@@ -2301,6 +2369,10 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally
it is not affected; left as-is (out of #488 scope).
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
**Signals:** music-video trash sweep, path-based identity, cross-kind safety · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath` · issues: #494, #477, #488, #496
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
@@ -2338,6 +2410,10 @@ media-server identity those base scanners rely on.
earns its keep once the sweep exists.
## 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds
`key: scan.jellyfin-mixed-content-library` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind.
**Signals:** mixed-content library, `LibraryMediaKind.Mixed`, per-kind sequential scan · paths: `JellyfinApiClient.Project()`, `ScanMixedLibrary`, `SynchronizeJellyfinLibraryByIdHandler` · issues: #489, #474, #488
**Mechanics:** local mixed libraries deliberately unsupported (`LocalFolderScanner.VideoFileExtensions` hazard)
A Jellyfin library whose collection type is `mixed` — or absent — now maps to `LibraryMediaKind.Mixed`
instead of being dropped by `JellyfinApiClient.Project()`'s `_ => None`. Scanning it runs the movie,
@@ -2399,6 +2475,10 @@ fixed here:
that scanner's other gaps (no `ItemId`/`Etag`, path-keyed identity — see #488).
## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
`key: iptv.logo-drives-bug-preset` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema.
**Signals:** channel logo, watermark bug preset, ChannelWatermark seeding · paths: `ChannelWatermarkImageSource.ChannelLogo`, `DbInitializer.Initialize`, `watermark.channel_bug_seeded` · issues: #67, #502
**Mechanics:** `DbInitializerChannelBugWatermarkTests`; `WatermarkResponseModel.imageSource`
#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
@@ -2438,6 +2518,10 @@ shared row already delivers the user-visible behavior with no schema change.
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
creating a second preset on the Watermarks screen.
## 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder.
**Signals:** QSV, native VA-API decode, Dolby Vision passthrough · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder.SetTonemap` · issues: #498, #505
**Mechanics:** `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`; migration `HasDefaultValue(true)`
`FFmpegProfile.HardwareAcceleration` picked one pipeline builder for **both** decode and encode, so an
Intel QSV profile decoded with the QSV decoder — which is materially less tolerant of imperfect H.264 than
@@ -2477,6 +2561,10 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi
FFmpeg has no `vaapi` hwaccel on Windows (and Windows QSV capabilities are over-reported), so on Windows
a QSV profile keeps QSV decode regardless of the flag.
## 2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)
`key: ci.small-lane-git-only` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap.
**Signals:** CI lane definition, per-job memory cap, small lane widening · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml` · issues: server-management#639, #406, #604, #574
**Mechanics:** sum-of-caps rule (#406/#604); second jazz runner at `--cpu-shares=128`
- **The `small` lane is defined by what a job *does*, not by how long it usually takes.** Both jobs
removed from it here were justified as small on a runtime argument that only held in the common case:
@@ -2504,6 +2592,10 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi
have already finished.
## 2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)
`key: ffmpeg.external-logo-graphics-engine` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut.
**Signals:** external-URL logo, WatermarkSelector, graphics engine · paths: `WatermarkSelector`, `FFmpegLibraryProcessService`, `ImageElementBase.LoadImage` · issues: #502, #67, #1, #510, #511
**Mechanics:** `WatermarkSelectorChannelLogoTests`; `ChannelLogoWatermarkOptions` helper
A channel whose logo is an **external URL** never rendered an on-screen bug, even with a
`ImageSource = ChannelLogo` watermark attached. `WatermarkSelector` resolved the URL correctly and then