From 2e34d9c47bd9d8b4fd6b23cd1cfe482ff1bc3932 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 11:29:05 +0200 Subject: [PATCH] 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) --- docs/decisions.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/docs/decisions.md b/docs/decisions.md index 41f85c7da..500ab6e4b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 ` 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 ` 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 ` 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