From 97062cc439b38e6835e6851006f670588eeeb0ea Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 11:25:50 +0200 Subject: [PATCH] feat(521): migrate decisions.md range C: tail #488+/#350/#58/#511 decision records to lifecycle schema [decisions-edit] Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/decisions.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/decisions.md b/docs/decisions.md index 41f85c7da..bcfe0acbf 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -2270,6 +2270,11 @@ for its whole slot. This closes that gap. ## 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:** `GetOrAddFolder` looks the existing folder up with a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` navigation collection (which is null on the remote/Jellyfin path). +**Signals:** folder lookup contract, remote scanner crash, ArgumentNullException, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488 +**Mechanics:** `ILibraryRepository.GetOrAddFolder`; test coverage in `LibraryRepositoryTests` + `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` `.Include(l => l.Paths).ThenInclude(p => p.LibraryFolders)` — which every `*FolderScanner` goes through. The @@ -2302,6 +2307,11 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally ## 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 by a library-scoped local-path diff and hard-deletes music videos gone upstream, since it has no server itemId/etag identity to diff against. +**Signals:** music-video trash sweep, path-keyed identity, cross-kind safety, empty-fetch guard reuse · paths: `JellyfinMusicVideoLibraryScanner`, `TrashMissingMusicVideos`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496 +**Mechanics:** `JellyfinMusicVideoLibraryScanner.ScanLibrary`/`TrashMissingMusicVideos`; integration tests extending the #488 harness + 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` (`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone @@ -2339,6 +2349,11 @@ media-server identity those base scanners rely on. ## 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 `LibraryMediaKind.Mixed` and runs the movie, TV and music-video scanners against it in sequence, instead of being dropped. +**Signals:** mixed-content library, LibraryMediaKind, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project`, `ScanMixedLibrary`, `ScanLocalLibraryHandler`, `SynchronizeJellyfinLibraryByIdHandler` · issues: #489, #474, #488 +**Mechanics:** `LibraryMediaKind.Mixed` dispatch in the Jellyfin sync handlers; per-kind scanners queried by `parentId` + `includeItemTypes` + 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, television and music-video scanners in sequence against that one library. @@ -2400,6 +2415,11 @@ fixed here: ## 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 logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset, not per-channel bug schema. +**Signals:** channel bug, ChannelWatermark, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource`, `DbInitializer.Initialize`, `WatermarkResponseModel`, `DbInitializerChannelBugWatermarkTests` · issues: #67, #286, #502 +**Mechanics:** `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker; `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` resolves the channel's own logo artwork at render time at all three watermark precedence levels, and @@ -2439,6 +2459,11 @@ shared row already delivers the user-visible behavior with no schema change. 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:** `FFmpegProfile.QsvPreferNativeDecoder` (default on, Linux-only) decodes QSV profiles with the native VA-API decoder while still encoding with QSV, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. +**Signals:** QSV, VA-API, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder`, `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md` · issues: #498, #505 +**Mechanics:** `QsvPipelineBuilder` decoder-mode branch; migration `HasDefaultValue(true)` on the nullable `bool?` column + `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 FFmpeg's native VA-API decoder and cannot carry Dolby Vision metadata. Jellyfin, on identical hardware, @@ -2478,6 +2503,11 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi 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:** the `small` CI lane is defined by what a job does (git-only), not its typical runtime; both `docker build` jobs move to `ubuntu-latest` so `small`'s per-slot memory cap can shrink and its concurrency widen. +**Signals:** runner lane definition, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `docker-build.yml`, `ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574 +**Mechanics:** `.gitea/workflows/docker-build.yml` and `ci-image.yml` `runs-on`; `docs/ci-cd.md` + - **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: `docker-build.yml`'s `build` is a 1-second skip on PR runs (but a real image build on main/tags), and @@ -2559,6 +2589,11 @@ again — the duplication is what let the defect exist in triplicate. Covered by ## 2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350) +`key: ffmpeg.hls-cold-start-burst` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none` +**Rule:** HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. +**Signals:** HLS cold start, readrate, work_ahead_limit, HlsSessionWorker, FFmpegKnownOption capability gate · paths: `HlsSessionWorker`, `SetRealtimeInput`, `FFmpegPlaybackSettingsCalculator`, `FFmpegKnownOption`/`HasOption` · issues: #350 +**Mechanics:** `SetRealtimeInput` readrate-burst option; `FFmpegKnownOption.HasOption` version-capability gate + - **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV. Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the @@ -2603,6 +2638,11 @@ again — the duplication is what let the defect exist in triplicate. Covered by ## 2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58) +`key: mcp.server-foundation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none` +**Rule:** `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. +**Signals:** MCP server, tool catalog, read-only default, ERSATZTV_ALLOW_WRITES, X-Api-Key, If-Match round-trip · paths: `ErsatzTV.Mcp`, `docs/mcp.md` · issues: #58, #286, #197, #76, #289, #63, #64, #65, #66, #67, #68 +**Mechanics:** `docs/mcp.md`; `ErsatzTV.Mcp` tool executor and catalog + The MCP server (issue #58, gated on #286 route-freeze + #197 security review, both closed) is built **fresh** as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping `/api/v1` — superseding the closed read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions frozen: @@ -2630,6 +2670,11 @@ read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions fro their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen. ## 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511) +`key: ffmpeg.remote-image-fetcher-bounded` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none` +**Rule:** remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. +**Signals:** remote image fetch, decompression bomb, MaxFrames, decode budget vs retention budget, SSRF accepted risk · paths: `IRemoteImageFetcher`, `HttpRemoteImageFetcher`, `ImageElementBase.LoadImage`, `DecoderOptions.MaxFrames`, `WatermarkElementRemoteImageTests` · issues: #511, #502, #289 +**Mechanics:** `HttpRemoteImageFetcher` (Infrastructure) over `IHttpClientFactory`; `EnsureDecodeAffordable`/`EnsureScaledFramesAffordable` pure-function budget checks + `ImageElementBase.LoadImage` fetched `http(s)` images with a throwaway `new HttpClient()` and `GetStreamAsync`. That is unbounded in three directions at once — no timeout override (the 100s `HttpClient` default), no response size cap, and a new connection pool per element — and it runs