5616fa6de5ce45b644cb02f4cdcc7d7f712aebf8
148
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5616fa6de5 |
fix(511): don't let the header pre-pass break animated PNG logos
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m38s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fourth adversarial pass cleared the security design — all three earlier bypasses are dead, DecoderOptions.MaxFrames is honored by every decoder that can produce multiple frames (GIF/WebP/TIFF exactly N, APNG N-1), and it bounds PEAK allocation, not just the final frame count (measured: 65 MiB capped vs 2.41 GiB uncapped on the same 600-frame GIF). But it caught a functional regression this PR introduced: a *default* `Image.IdentifyAsync` throws InvalidImageContentException on most APNGs that `Image.Load` reads back perfectly — including files ImageSharp's own PngEncoder wrote. Reproduced independently: 13 of 16 shapes throw, and `MaxFrames = 1` on the Identify fixes all 16 with dimensions intact. Since #502 routes ordinary channel-logo watermarks through this path, an admin with an animated PNG logo would have silently lost their watermark to a log line — a hardening change breaking working content. The existing tests could not see it: they use 64x64, which happens to be one of the few shapes a default Identify handles. Now pinned with a 288x288 shape that asserts the default Identify DOES fail and that DecodeRemoteImage decodes it anyway, in full. Also, from the same pass: - document the REAL enforced peak (up to 3x the nominal 50 MP budget, since detecting "over the limit" means decoding past it) instead of restating the nominal number. Tightening the single-frame allowance to budget/3 would reject legitimate 8K stills, so the overshoot is deliberate; it is ~600 MB against the ~36 GiB it replaces - correct the MaxFrames off-by-one claim: N-1 is APNG-specific, not universal, so the stated rationale for +2 was wrong for three of the four animated formats |
||
|
|
d4e112f1e9 |
fix(511): bound the DECODER, not the header's frame count
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 15s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m39s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Second adversarial re-review defeated the product budget too, and the
mechanism generalizes: the budget was enforced on a number the decoder
does not honor.
Measured on ImageSharp 3.1.12 (reproduced independently before fixing):
600-frame APNG -> Identify: FrameMetadataCollection.Count = 0
Load: Frames.Count = 600
So EnsureDecodeAffordable(w, h, 0) charged Math.Max(0,1) = 1 frame —
the most permissive possible reading. A 4000x4000 x600 APNG is ~134 KiB
on the wire, is charged 16 MP, and decodes to ~36 GiB: 2.5x worse than
the GIF the previous commit exists to stop, at half the wire size. The
retention budget could not backstop it — that runs after LoadAsync, so
the process OOMs first, killing every concurrent stream.
GIF, WebP and TIFF report honestly; PNG/APNG is the sole divergence,
which is the point: you cannot audit every format, so the header cannot
be the source of truth.
DecodeRemoteImage now:
- checks header DIMENSIONS only (trustworthy; a GIF image descriptor
exceeding its logical screen is clamped by the decoder, verified)
- derives how many frames of that size the budget affords
- passes that to DecoderOptions.MaxFrames, which the DECODER enforces
whatever the header claimed. Measured: MaxFrames = N yields N-1
frames, so it asks for affordable + 2 — decoding one more than allowed
is what distinguishes "at the limit" from "over it" without silently
truncating a legitimate animation
- re-verifies the real image.Frames.Count after decoding, disposing and
rejecting if over
Also adds wiring coverage for the retention budget (M4): deleting its
call site now fails a test — negative-controlled, build verified before
trusting the result.
docs/decisions.md records both failed attempts, because the lesson is
the generalizable part: independent caps do not compose into a budget,
and a limit the decoder does not enforce is not a limit.
|
||
|
|
9a2096f340 |
fix(511): budget decode by the PRODUCT, not by independent caps
Adversarial re-review of the first fix defeated its decode guard with a measured payload: a 2500x2500 x600-frame GIF is ~60 KiB on the wire, passes the 50 MP dimension check (6.25 MP) AND the 600-frame check (exactly 600), and costs ~14 GiB to decode — strictly worse than the 30000x30000 PNG the guard was added to stop, at 1/60th the wire size. Checking dimensions and frames independently never bounded the decode. - decode budget is now width x height x frames <= 50 MP, as one product; a zero frame count is charged as one so an unenumerable header cannot zero it out - new retention budget: frames x scaledWidth x scaledHeight <= 200 MP. Independent of the decode budget in both directions — a 100x100 source is trivial to decode but retains ~5 GB of SKBitmap once every frame is scaled to 1920x1080, since LoadImage clones and resizes each frame to output resolution and keeps them - both budgets are pure functions (EnsureDecodeAffordable, EnsureScaledFramesAffordable) so the arithmetic is tested at every boundary without materializing multi-gigabyte images - the frame guard had NO coverage before; it does now - fail loudly on a non-seekable fetcher stream instead of letting Position throw NotSupportedException into the blanket catch - test the copy over-read against the ACTUAL rented buffer length (ArrayPool.Rent(81920) returns 131072), not the requested 81920 docs/decisions.md corrected: it claimed the byte cap bounded the decode-bomb surface and that the header check closed the class. Both overstated. An append-only file that is confidently wrong is worse than one with a gap. |
||
|
|
e132c422bb |
fix(511): bound remote graphics-engine image fetches
`ImageElementBase.LoadImage` fetched http(s) images with a throwaway `new HttpClient()` + `GetStreamAsync`: no timeout override (the 100s default), no size cap, unbounded redirects, no pooling — all inside stream startup, while ffmpeg waits on the pipe. #502 routed ordinary channel-logo watermarks onto that path, widening a pre-existing weakness. Introduce `IRemoteImageFetcher` / `HttpRemoteImageFetcher`, modelled on the neighbouring `IRemoteStreamProber`: - deadline covers headers AND body (linked CTS + `CancelAfter`, client `Timeout = InfiniteTimeSpan`) — under `ResponseHeadersRead` the body read falls outside `HttpClient.Timeout` (the #289 lesson) - 10 MiB cap enforced during the copy; `Content-Length` is only a cheap early reject, since it can be absent or a lie - permissive content-type check (rejects an HTML error page, allows a missing type and octet-stream) - pooled via `IHttpClientFactory`; redirects capped at 3, not 50 A byte cap does NOT bound decoding, so `DecodeRemoteImage` additionally reads declared dimensions + frame count from the header and rejects before `Image.LoadAsync` allocates (50 MP / 600 frames). A 4 KB PNG declaring 30000x30000 costs ~3.6 GB to decode and passes every wire-size check — caught by adversarial review of the first version of this change, which capped bytes and wrongly claimed that was decode-bomb protection. Not cached and SSRF not mitigated — both deliberate, with the reasoning recorded in docs/decisions.md. fixes #511 |
||
|
|
a88240dcec |
feat(#58): ErsatzTV.Mcp — read + cautious-write MCP server over /api/v1
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 17s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fresh stdio JSON-RPC MCP server wrapping the frozen /api/v1 surface, superseding the closed read-only PR #76. 26 read tools (six families + search/all-items & search/artists discovery) and cautious-write CRUD: collections (incl. idempotent membership adds for #487), smart collections, schedules, playouts, channels (create/update/delete/reset), and a Jellyfin-focused media-source sync/scan slice. Writes gated behind ERSATZTV_ALLOW_WRITES (default false, runtime-enforced). Security baseline carried forward from PR #76/#289: read-only backstop, JSON-RPC DoS guards + bounded stdin reader, per-request CTS over headers+body, response-size cap, arg validation vs InputSchema, reverse-proxy prefix preservation. Machine-key auth (X-Api-Key, CSRF-exempt). If-Match/ETag round-trip for the one replace-all PUT that honors it. Cold-review fixes folded in: - HIGH: reject control chars (CR/LF) in the ifMatch value before it reaches TryAddWithoutValidation — SocketsHttpHandler writes it verbatim, so a crafted value could smuggle headers onto the X-Api-Key request. - Cache the empty-args JsonDocument (no per-call pooled-doc leak). - Accept explicit JSON null for optional fields so a nullable API field (e.g. dailyRebuildTime) can be cleared as documented. Deferred (documented): the ~40-field replace-list writes and redesign workflow tools (#63-#68). Docs: docs/mcp.md, docs/README.md index, docs/decisions.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1359bb6135 |
fix(350): burst-read the first HLS segments so cold start isn't readrate-bound
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m52s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
`-readrate 1.05` paces input reading at wall clock so a channel behaves like live TV, but it applies from the very first read. With 4s HLS segments and the segmenter waiting for the first one, the playlist could not appear sooner than ~4/1.05 = 3.8s, so every tune-in that did not win a work-ahead slot paid a multi-second wait. Add `-readrate_initial_burst` (FFmpeg >= 6.1) next to `-readrate` on the normal playback path, gated on runtime capability detection via the existing `FFmpegKnownOption`/`HasOption` machinery, whose option list had simply been empty. Measured on real prod media: time-to-first-playlist 5369/5344ms -> 648/649ms. Root cause detail: the cold-start bimodality earlier rounds could not explain was never about the media. `HlsSessionWorker` grants an unthrottled start only while `_workAheadCount < work_ahead_limit` (prod: 1), so concurrent tune-ins fall back to the throttled path. Confirmed on prod with three concurrent tunes: firstGop 866ms for the slot winner vs 3845ms and 6357ms. This also falsifies the issue's ranked #1 driver — accurate-seek decode-discard measures 30-100ms on real media, and probe caps 20-50ms; neither can account for seconds. Still images are excluded: their video input is paced by the realtime filter and takes no readrate, so a burst would only run a song's separate audio input ahead of the video. Concat/WrapSegmenter keep the unburst single-arg constructor. fixes #350 |
||
|
|
66448e1abf |
fix(502): correct the deco-scoping claim, extract + test the routing guard
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 14s
Build CI Toolchain Image / Build & push CI image (push) Successful in 1m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review found a documentation defect, not a code one: both docs/decisions.md and the WatermarkSelector comment asserted the deco path was unaffected by this change. That is true of the *resolution* half and false of the *routing* half. SelectWatermarks puts deco-derived options into the same list the routing guard filters, so a deco watermark whose resolved path is a URL is rerouted to the graphics engine too — including the generated-initials localhost URL, which only the deco path still emits and which plausibly rendered through ffmpeg before. That reroute is intended (routing by what the path is beats routing by provenance, which would drift), so the fix is to say so accurately rather than to narrow the guard. Also records the accepted per-frame cost asymmetry the entry previously argued on correctness grounds alone. The guard is extracted as CanUseFFmpegNativeWatermark so it can be tested directly — review's highest-value gap was that the half of the fix which decides whether pixels appear had no automated coverage, only the one-off live E2E. Nine cases pin it, including the localhost-fallback reroute. Both deferrals now point at real issues instead of an unverifiable "tracked separately": #510 (deco vs precedence-level missing-logo policy) and #511 (remote-fetch hardening — timeout, size cap, redirects, pooling, caching, SSRF). Also pins scheme-case insensitivity in the selector. |
||
|
|
f9bd245158 |
fix(502): render the on-screen bug for external-URL channel logos
A channel whose logo is an external URL never rendered a watermark, even
with an ImageSource=ChannelLogo watermark attached. WatermarkSelector
resolved the URL correctly and then existence-checked it on the
filesystem — File.Exists("https://…") is always false — so all three
precedence levels (playout item, channel, global) logged "Channel logo
no longer exists" and returned None. The channel editor advertises the
URL as winning over an uploaded logo, which was true for the guide
listing and silently false for the bug.
External artwork passes through rather than being downloaded into the
image cache: that is already the convention everywhere else (M3U, XMLTV,
SPA JSON all emit the raw URL), no fetch->SaveArtworkToCache glue exists,
and the render path does not need it — ImageElementBase.LoadImage already
fetches an http(s) path with HttpClient and decodes it for real pixel
dimensions.
A remote-URL watermark is therefore forced onto the graphics engine
instead of the ffmpeg-native shortcut, which would otherwise hand the URL
to ffprobe and ffmpeg as a bare -i argument, putting an unbounded network
fetch inside stream startup.
The three gated precedence levels now share one ChannelLogoWatermarkOptions
helper — the triplicated block is what let the defect exist three times
over. Scope held narrow: the generated-initials localhost fallback (#1)
stays disabled behind an explicit comment and a scope-guard test, and the
deco path keeps its own long-standing unchecked policy.
Verified by live-E2E against a real channel playout with an external-URL
logo: origin/main renders 0 logo pixels and logs the "no longer exists"
warning verbatim; this branch renders the logo in the expected region.
Whitespace-only reformatting in FFmpegLibraryProcessService.cs is the
fix-as-you-touch format gate on pre-existing violations, plus a BOM strip.
fixes #502
|
||
|
|
4263cf7919 |
ci(508): move both docker build jobs off the small lane
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Failing after 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 14s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build CI Toolchain Image / Build & push CI image (push) Successful in 2m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
`runs-on: small` carried two jobs that are not small: docker-build.yml's image build, and ci-image.yml's toolchain buildx. The second reads as lightweight because it is "docker-only, no toolchain needed — it *builds* the toolchain", which is true and yet describes the heaviest job in the lane. A lane's per-job memory cap is set by its worst member, not its median, so these two pinned `small` at --memory=10g. On bumblebee's 25 GiB — also the prod media host — that permits exactly ONE slot, and four jobs shared it. So "widen the lane" and "keep the heavy jobs" were never simultaneously available. The symptom that forces the issue is not queue wait. A saturated lane also wedges DISPATCHED jobs in act's setup phase: >10 min in_progress, no log file written at all, then failure, before Checkout runs. That is where "decisions.md is a known flake, just rerun it" came from — the rerun works only because it lands after load clears, so a capacity problem read as a bug in the guard. With both builds on ubuntu-latest, `small` is a checkout plus a `git diff` and server-management#639 caps it at 1 GiB, widening it to 4 slots across two hosts while committing LESS RAM to CI than the single slot did. so it cannot be dispatched until the jobs it would queue behind have finished. refs #508 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
374528250b |
fix(498): address final-review findings (Windows guard, Nut parity, docs, test)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m49s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
From the Fable whole-branch review:
- M1: guard native VA-API decode with !OperatingSystem.IsWindows() (no vaapi
hwaccel on Windows; QSV caps over-reported there) — DRY'd into a preferNativeDecode local.
- L2: IsIntelVaapiOrQsv also matches decoder mode Vaapi, preserving Intel
audio-dup parity on the (producerless) Nut-output branch.
- L1: replace the vacuous ShouldNotContain(" deinterlace_qsv") with an
occurrence-count assertion that actually catches a second bare occurrence.
- N1/H1: decisions.md — correct the column to nullable-with-default (not NOT NULL),
and record the accepted HDR software-tonemap trade-off + Linux-only guard, with
the tonemap_qsv optimization tracked in #505.
H1 (HDR tonemap reroute) accepted-and-deferred per that decision; #505 filed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
79c396d5ed |
docs(498): record QsvPreferNativeDecoder decision
Field-reference update skipped: docs/channels.md and docs/domain-model.md have no per-field FFmpeg-profile catalogue (channels.md's Encoding bullet is a one-line summary, not a field list; grep for QsvExtraHardwareFrames or HardwareAcceleration finds no such list in either doc). |
||
|
|
bb7f57d04d | docs(67): record the shared-preset decision, watermark seeding, and the additive DTO field | ||
|
|
2cf90fb44f |
feat(489): support Jellyfin mixed-content libraries (#493)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has started running
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Jellyfin libraries typed `mixed` were dropped by JellyfinApiClient.Project's `_ => None` with no log line, so music and standup content could not be ingested without a local-library workaround that bypassed Jellyfin entirely. Adds LibraryMediaKind.Mixed, maps "mixed"/absent/blank CollectionType onto it, and gives SynchronizeJellyfinLibraryByIdHandler a Mixed arm composing the three existing per-kind scanners. Jellyfin classifies items server-side via includeItemTypes, so the passes see disjoint sets; reconciliation is type-scoped and cannot cross-delete. No new scanner and no DB migration -- MediaItem is TPT keyed on LibraryPathId, so heterogeneous contents were already legal. Segregation falls out of the model: a library is a place (one path <-> one Jellyfin library <-> one ErsatzTV library), so music/standup cannot leak into Movies or TV Shows. Also removes the silent-success `_ => Unit.Default` from both scanner dispatchers, which returned Right for an unhandled kind and stamped LastScan as though a scan had run, and rejects Mixed for local libraries at the API. Deliberately Jellyfin-only: local scanners share one video extension list and would claim each other's files, and LibraryFolder etags are keyed by LibraryPathId with no notion of kind. Verified by live E2E against a real Jellyfin, including the interaction with #494's reconciliation sweep. Four cold review rounds, all MERGEABLE. fixes #489 Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
5b0d8b5d09 |
fix(494): reconcile removed music videos in Jellyfin scanner (#495)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m25s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
fix(494): reconcile removed music videos in Jellyfin scanner (#495) JellyfinMusicVideoLibraryScanner did add/update only, so a music video or artist deleted on the Jellyfin side lingered forever. Add a library-scoped trash sweep (TrashMissingMusicVideos) gated by the #477 empty-fetch guard. fixes #494 Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
d3db2f6af1 |
fix(488): resolve LibraryFolder from DB so Jellyfin music-video scans stop NRE'ing
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
GetOrAddFolder read libraryPath.LibraryFolders, a navigation collection only
eager-loaded on the local scan path (via GetLibrary). The Jellyfin remote sync
path takes its LibraryPath off the JellyfinLibrary entity, where LibraryFolders
is null, so .Filter(null) threw ArgumentNullException('source') on the first
item of every Jellyfin music-video scan — a feature that had therefore never
run in prod, CI, or locally.
Look the folder up from the DB by (LibraryPathId, Path) instead, removing the
implicit eager-load contract entirely (correct for all nine callers) and
documenting it on ILibraryRepository. null != empty is preserved so a re-scan
does not insert duplicate LibraryFolder rows. No new hot-path cost: local
scanners already query GetParentFolderId per folder just before this call.
Tests:
- LibraryRepositoryTests: GetOrAddFolder with a null LibraryFolders (the exact
remote-path shape) creates the folder, is idempotent on re-scan, and persists
a supplied ParentId.
- JellyfinMusicVideoLibraryScannerTests: an end-to-end scan of one synthetic
music video, wiring the REAL LibraryRepository/ArtistRepository/
MusicVideoRepository against in-memory SQLite (the existing MediaServer*
scanner tests mock every repo, which is why the bug escaped), asserts the scan
completes and creates Artist + MusicVideo rows with a real LibraryFolder.
Both proven non-vacuous against the reverted fix (each reproduces the issue's
ArgumentNullException). decisions.md entry added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4a9502cbb5 |
fix(480): probe external-JSON remote-stream URLs before handing them to ffmpeg
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m26s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
External-JSON playout channels build their own /media/plex/{server}/{plexFile}
URL in ExternalJsonPlayoutItemProvider.StreamRemotely and the handler assigns it
without routing through ValidatePlayoutItemPath, so the #473 class survived here:
a media item gone from the server 404s under ffmpeg (exit 8) and the same dead
item is re-selected for its whole slot.
Route StreamRemotely through the same IRemoteStreamProber seam #473/PR #479 added
for the generated-playout path. Probe runs before the Plex metadata round-trip
(the URL needs only server.Id + plexFile), so a gone item skips it. An unavailable
stream returns PlayoutItemNotAvailableFromMediaServer, which the handler already
maps to a real-error card. The fail-open policy (redirected-404 only) lives inside
IRemoteStreamProber, so this second call site duplicates only the decision to probe.
Tests: ExternalJsonPlayoutItemProviderTests pins both directions; proven non-vacuous
by neutralizing the probe. docs/decisions.md gets a #480 entry closing the #473
scope gap (append-only: the old #473 entry is cross-referenced, not edited).
fixes #480
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
aa32fd78dd |
fix(477): guard media-server library sweeps against successful-but-empty fetches
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
A successful fetch returning zero items made existing.Except([]) flag the ENTIRE library FileNotFound in one scan — feeding EmptyTrashHandler's permanent delete and emptying every affected collection (dead channels). Add a shared MediaServerReconciliationGuard that skips (and logs a Warning) the sweep when incoming==0 while items exist, wired into the three library-level sweeps (Television shows / Movie / OtherVideo). An empty incoming set is indistinguishable at scan time from a mid-restore / emptied-upstream error (both report a zero total), so this deliberately overrides #476's degenerate "last item removed => empty incoming => flag" case. #476's cascade still fires for partial deletions (survivors present); its characterization test moves from an empty incoming to a survivor+removed partial-deletion case. Tests: policy table (MediaServerReconciliationGuardTests) + per-scanner integration proving the wiring (empty incoming + non-empty existing flags/reindexes nothing). Proven non-vacuous by neutralizing the guard. Nested TV season/episode sweeps left unguarded (bounded blast radius); ratio-threshold + projection-failure detection deferred to a follow-up. docs/decisions.md updated. Fixes #477 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bad19f8d26 |
fix(473): review fixes — only a redirected 404 fails closed
Adversarial review of PR #479 found the stated fail-open contract was not what the code measured, plus four smaller gaps. All fixed here as a follow-up commit (no amend/force-push). High — a 404 from ErsatzTV's OWN endpoint was treated as "media gone". /media/{provider}/... is served by InternalController, which returns NotFound when the media source is unconfigured or momentarily missing (a media-source edit that deletes+reinserts connections, a restore, a partially-configured server). Probing for "any 404" therefore failed CLOSED for every item on that source -- exactly the case the fail-open contract exists to prevent. A media-server 404 always arrives after a redirect, so an un-redirected 404 is now treated as available. Medium — the new switch label was untested and its benefit overstated. maybeDuration/finish are computed before the switch, so `default:` already sized the error card to the next playout item; the label only changes the caption. The handler test asserted call counts only, so deleting the label still passed. It now asserts the error message, and removing the label fails the test (verified). Medium — Plex/Emby branches changed but had no coverage. Added an Emby handler test asserting the probe is called with the emby URL. Low — caller cancellation was swallowed and pinned as desired behaviour. A shutdown / client disconnect is a genuine signal, not a probe failure; it now propagates, and only the probe's own 2s timeout fails open. Low — the response stream was disposed unread, aborting the connection instead of returning it to the pool. The one requested byte is drained. Nit — fully-qualified RangeHeaderValue replaced with a using. docs/decisions.md corrected where it overstated: the switch label's role, the "fixes the class for all three media servers" claim (external-JSON channels bypass ValidatePlayoutItemPath entirely -- filed as #480), and the unmeasured latency assertion. Deferred HEAD-instead-of-GET recorded with its reason rather than silently dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
90dc864ce5 |
fix(473): probe media-server remote streams before handing the URL to ffmpeg
Tuning a channel intermittently hard-failed with ffmpeg exit 8 and
`Server returned 404 Not Found` on /media/jellyfin/{itemId}.
Root cause: ValidatePlayoutItemPath checked `File.Exists` on the local
branch, but the three media-server remote-stream branches returned
`http://localhost:{port}/media/{plex,jellyfin,emby}/{id}` unconditionally.
When the media was gone from the media server too, validation "succeeded"
and ffmpeg was launched against a URL that 404s.
That bypassed the good error path the handler already had
(PlayoutItemDoesNotExistOnDisk renders an error card sized to run until
the NEXT playout item, so the dead item is skipped) and instead landed in
HlsSessionWorker's generic ffmpeg-failure path, which sizes its error card
to the failed 44s work-ahead chunk and then re-selects the SAME broken
item -- a repeating error card for the item's whole slot (~22 min).
Restore the method's own invariant: every PlayoutItemWithPath it returns
has been checked for existence. A definitive 404 now returns the new
PlayoutItemNotAvailableFromMediaServer error, handled in the same switch
arm as PlayoutItemDoesNotExistOnDisk.
The probe is deliberately fail-open: only a 404 reports the media gone.
A timeout, 5xx, auth error or transport failure reports available, so a
probe that cannot answer can never break a tune that would have worked.
That contract is pinned by tests so a later refactor cannot invert it.
Rejected alternatives (see docs/decisions.md): resizing the
HlsSessionWorker retry loop (cannot distinguish a dead item from a
transient transcoder failure -- prod has live VAAPI hwupload -22 failures
that must keep retrying), and writing MediaItemState from the streaming
path (breaks scanner ownership, and would not have fixed this: the item
is RemoteOnly, which PlayoutBuilder's skip does not exclude).
Scanner-side follow-ups filed separately: #476 (FileNotFound does not
cascade show -> episodes, the reason dead items keep being scheduled),
fixes #473
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c5369b1d69 |
ci(412): sample true peak-anon in the test job, not cache-inflated memory.peak
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m12s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The test-job memory instrument (#411) reported memory.peak — the high-water mark of memory.current, which charges reclaimable page cache to the cgroup. A build does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak, and page cache is reclaimed under a tighter cap rather than OOM-killed. Sizing a per-job cap (server-management#604) off memory.peak therefore inverts the decision. The OOM-forcing quantity is peak anon, which the kernel exposes no counter for and which the end-of-job split misses (a job that peaks mid-dotnet-test then frees reports a low anon). New scripts/ci-peak-anon.sh: a `start` step (before Build/Test/Coverage) launches a detached background sampler tracking the high-water mark of cgroup anon; a `report` step (last) stops it and prints the sampled peak anon as the headline, keeping memory.peak + end-of-job split as a cache-inflated ceiling and reference. Both continue-on-error + fail-open so they never redden a build. Validated on bumblebee: survives step-boundary re-execs, catches a transient 2.5 GiB anon spike the snapshot reports as 0, stops cleanly on kill, degrades gracefully. Compiler-server A/B (swap-off, sampled peak-anon, n=2 interleaved): OFF (CI config) ~5.84 GiB consistent; ON (defaults) 6.3-7.6 GiB, always higher, + a ~3 GiB resident VBCSCompiler. Disabling the servers is worth it, but OFF sits right at 6 GiB for the build phase alone and the test job adds test+coverage, so #406's "budget loosens well under 6 GiB" premise is not supported. Size the cap off the live test-job sampler. Docs: ci-cd.md instrument section rewritten (peak-anon headline + A/B table + premise verdict); decisions.md entry added. No .cs touched. fixes #412 |
||
|
|
33657b4753 |
docs(469): sweep remaining stale format-job cost/memory claims (review)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Cold adversarial review (MERGEABLE) flagged two spots still describing the pre-#469 heavy `format` job as current, plus a wording imprecision: - ci-cd.md CI-lane table row: annotate `format` runtime 37s → ~0.5s (#469). - ci-cd.md #406 memory narrative: note the 3.95 GiB Roslyn heap is now moot (folder mode loads no workspace); api-docs remains the lane's real consumer. - Precise the coverage mechanism in ci-cd.md + decisions.md: the naming rule passes the full gate because naming violations have no `dotnet format` batch code-fixer (so `--verify-no-changes` sees no change), not merely a severity floor. Docs-only; no workflow/hook logic change from the reviewed commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eafb2e39e2 |
perf(469): format gate uses dotnet format whitespace --folder (~480s → ~0.5s)
Build ErsatzTV Image / decisions.md append-only (pull_request) Waiting to run
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
The blocking `format` CI job and the Husky pre-commit hook verified changed .cs files with `dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include <files>`. `--include` only narrows *which* files are checked, never what gets loaded: the full recipe loaded the ~10-project MSBuild workspace and built a Roslyn compilation per project before checking a single line (~480s locally, whole-solution). Switch both to `dotnet format whitespace . --folder --verify-no-changes --include <files>`, which treats the tree as a plain folder of files, skips MSBuild/Roslyn entirely (~0.5s), and needs no `dotnet restore` (NuGet-cache + Restore steps removed). Coverage is unchanged: folder mode reads .editorconfig and enforces exactly the gate's purpose — whitespace + charset (BOM). Proven non-vacuous (error WHITESPACE on a trailing-space line, error CHARSET on a prepended BOM, exit 0 clean). The full gate never enforced the style/analyzer pass either — a warning-severity naming violation passes the full solution format (exit 0) — and the analyzers that must block (NU1904, S3981) are enforced at compile via WarningsAsErrors, not by this job. Docs: ci-cd.md Formatting section + the obsolete #406 memory note; decisions.md. fixes #469 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
be25df670e |
feat(431): TTL-cache health-check results; ?refresh=true forces a fresh run
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Failing after 12m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 14m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
GET /api/v1/health re-ran all 14 health checks on every request, 4 of which shell out to ffmpeg/ffprobe via CliWrap — so each poll spawned ~4 subprocesses. The existing HealthCheckSummary cache was write-only. Cache the full result list for 30s inside HealthCheckService keyed on a new "healthcheck.results" entry; a non-forced call returns it on a hit, skipping the checks and the (subscriber-less) summary publish. Add a `bool forceRefresh` first parameter to IHealthCheckService.PerformHealthChecks: the API poll path reads the cache, while startup (RunHealthChecksService) and the troubleshooting support bundle force a fresh run. Refresh surface: GET /api/v1/health gains an optional `[FromQuery] bool refresh` (additive, follows the ?deep= exemplar); the SPA "Refresh health" button calls /api/v1/health?refresh=true, the initial/poll load does not. Tests: HealthCheckService cache-hit vs force-bypass (mutually opposing, non-vacuous), handler+controller refresh-flag threading, SPA refresh URL. Docs: decisions.md 2026-07-19 (#431), api-conventions §2; regenerated v1.json. fixes #431 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d2d678aae8 |
test/docs(404): add unrelated-edit round-trip test; correct MultiCollection-only rationale
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m10s
Review + live-E2E follow-ups (no behavior change): - Add a test proving the canonical silent-reset trap directly: a rename-only save round-trips both weights untouched (the prior test only edited the weight it asserted). Cold review nit. - Correct the rationale in itemRules.ts + decisions.md: WeightedShuffle is MultiCollection-only in the SPA for *meaningfulness* (per-source weights need 2+ sources), NOT because the classic write path rejects it — live-E2E confirmed the classic engine ACCEPTS it on a plain Collection (200) and degrades to fair-share. The rejection is on the separate playlist/block write paths, whose editors keep their own order lists and already omit it. Live-E2E (real API): weighted multi-collection create + read round-trips weights; rename-only PUT preserves them (no silent reset); WeightedShuffle persists on a classic MultiCollection schedule item. Ratio itself is pinned by the existing PlayoutBuildGoldenTests.Classic_weighted (3:1), untouched by this SPA change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d639946b5c |
feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m39s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The UI half of #70 (backend + API shipped in PR #402). Pure SPA + docs — no new endpoint/DTO (`weight` was already on MultiCollectionItemRequest/Response, and `WeightedShuffle` already in the PlaybackOrder enum). - Multi-collection editor (`/app/multi-collections`): per-source weight input (1..1000, mirroring the API validator), a computed % share (3:1 shows 75/25), and a "Reset to fair share" action (fair-share = all weights 1, decisions.md 2026-07-17 — a reset, not a separate mode). Weight round-trips through the draft (read in itemsFromMultiCollection, written in toItemRequest) so the replace-all PUT never silently resets it. - Classic schedule editor: `WeightedShuffle` offered as a Playback Order ONLY for MultiCollection sources (itemRules `MULTI_COLLECTION_ORDERS`) — it needs per-source weights and the write path rejects it elsewhere. Excluded from fillWithGroup like ShuffleInOrder (PlayoutBuilder schedules fill-groups per-group, incompatible with whole-collection weighted share). - `Input` gained min/max/inputMode/onBlur passthroughs for bounded numeric fields (reusable by #425's weight UI); weight held as a string for smooth editing, clamped on blur and at save so an out-of-range value never 400s. - Docs: domain-model + spa-conventions (replace-all round-trip trap, bounded numeric input pattern) + decisions.md entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
df8c5202d6 |
fix(409): report never-scanned LastScan as null for API/MCP parity (migration + read coercion) (#459)
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build CI Toolchain Image / Build & push CI image (push) Failing after 10m41s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m58s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
922b1ef53c | docs(398,420): document the #420 skip + record build-once measured-and-rejected | ||
|
|
0f34c86afa |
feat(403): make unsupported PlaybackOrder loud at build time + tripwire
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 37m32s
Adding a new PlaybackOrder was unsafe by construction: three build-time dispatch sites turned an unknown value into an enumerator silently. Classic substituted RandomizedMediaCollectionEnumerator (the // TODO default arm), PlaylistEnumerator had no default arm so the item was dropped, and BlockPlayoutBuilder's allow-list continue skipped it. (#70 already made YAML/Scripted log a warning and MultiCollectionGroup throws.) - each silent site now logs a Warning naming the order + engine + the fallback taken; the fallback itself is preserved so a live channel never goes dark on one misconfigured item and scheduler goldens do not move. - PlaylistEnumerator.Create gained an optional Option<ILogger> (it was static with no logger -- why the drop was unreportable); loggered callers pass it. - BlockPlayoutBuilder gained an explicit Random arm (it previously reached an enumerator only via the coincidental _ => fallback) and a loud defensive fallback. - new PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the enum for every engine, so a new order fails the test until classified. BlockPlayoutBuilder consumes the matrix for its allow-list. - write-path rejection left unchanged (#70 closed the persistence hole; the perimeter has been wrong three times per decisions.md); reverse _ => None mappings reviewed and deferred (different axis; making them loud would warn on legit enumerator types). docs/decisions.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eb47aed767 |
feat(396): collapsible sidebar + nav-group accordions
Add two independent, persisted collapse states to the shell sidebar: - a header toggle that collapses it to a 60px icon rail - collapsible accordions per labeled nav group (Media, System); the unlabeled Primary group is always open, default-collapsed groups State + persistence live in web/src/app/sidebarState.ts (useSidebarState); AppShell stamps ctv-app-shell-collapsed on the shell root and the rail look is CSS-driven. Two namespaced localStorage keys (ctv-sidebar-collapsed, ctv-sidebar-groups) per the persisted-UI-state convention. In the rail, accordions are ignored (all items shown icon-only, label kept in the a11y tree + surfaced as a title tooltip, badges as a corner dot); active-route indicator works in both states; grid-column transition respects prefers-reduced-motion. Tests: colocated sidebarState.test.ts + a new describe in App.test.tsx (default-collapsed, accordion toggle+persist, rail, reload persistence). Docs: spa-conventions §13 + decisions.md 2026-07-18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c57fbf9826 |
docs(293): note the pageNum upper clamp (MaxAllItemsPageNum) in the decision + api-conventions
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13m20s
Fix delta re-review flagged the decision entry + §5 note still described the pre-fix pageNum = Math.Max(0, pageNum); the shipped code clamps the upper bound too (0..2_000_000) to stop pageNum*pageSize overflowing int to a 500. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ac7965dee4 |
feat(293): paginate GET /api/v1/search/all-items to cap DoS exposure
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m39s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14m31s
The all-items endpoint fired ten index searches with limit:0 (every hit), so a broad authenticated query materialized the whole index into one response. Add optional pageNum/pageSize (clamped 1..1000; pageNum 0..2_000_000 so skip can't overflow int) and an additive per-kind Totals on the response; the SPA add-all flow now pages to completeness instead of a single unbounded fetch. - SearchController.SearchAllItems: clamp params (Logs §1 precedent), map Totals - QuerySearchIndexAllItemsHandler: skip=pageNum*pageSize, limit=pageSize, read SearchResult.TotalCount per kind - SearchResultAllItemsResponseModel: additive Totals (frozen-v1-safe) - web/src/api/search.ts: getSearchAllItems paging params + getAllSearchItemIds (pages until each kind hits its total; empty-page safety break) - tests: controller clamp/thread/totals, handler skip/limit/totals, SPA paging - docs: decisions.md 2026-07-18 (#293), api-conventions.md §5; regenerated OpenAPI Design: issue option (a) full pagination, operator-confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e364b338e6 |
feat(425): per-source rotation weights + query corrections for auto-tune channels
Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.
Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.
- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
(un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
(dual-provider migration); owned rows are hidden from the collection lists and
cascade-cleaned on channel delete.
Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c91c34f113 | docs(176): decisions + spa-conventions for the SmartCollection rule builder | ||
|
|
f44eee85c5 |
feat(386): Auto-Tune per-channel DetailPanel slide-over (SPA)
Adds a right-hand "Configure" slide-over to each Auto-Tune Preview row, making a proposed channel editable before bulk-create — against the shipped #384/#385 backend only, so no control lacks a wire target. - New reusable SlideOver primitive (components/overlay.tsx), sharing a useOverlayBehavior hook (focus/scroll-lock/Escape/scrim) with Dialog. - Extract the Channel Builder's advanced-options model to builder/advancedOptions.tsx (enum catalogs, ADVANCED_KEYS, effectiveValue, INHERIT/omit useAdvancedOverrides hook); ChannelBuilder imports it unchanged (its tests pass byte-for-byte). The DetailPanel writes its own field JSX over the same hook — shared logic, per-screen layout. - Panes: identity (name/number + logo upload), Playback (Shuffle/Always-playing → advanced.playbackOrder/playoutMode), per-channel template picker, Advanced disclosure, lean read-only Query&size, read-only Content-sources via GET /members. - getAutoTuneChannelMembers API client (#384 read endpoint) + tests. - Screen-scoped §8 unsaved-changes guard + "Edited" row badge. - Dropped as backend-less decoration: MiniEpg, bug-initials generator, query text. Deferred to #425 with an in-pane hint: per-source weight steppers + corrections. - Docs: spa-conventions §11 (SlideOver + shared advanced-options), decisions.md. Refs #386 |
||
|
|
ed6c43065f |
feat(164): guided remediation for health checks (server-declared {Kind, Target})
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m49s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m35s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13m23s
Make the ~14 health checks actionable: each check that has a fix now declares
where to go, and the SPA acts on it.
Backend:
- Widen domain HealthCheckLink (string Link) -> (string Target, HealthCheckLinkKind
Kind) with ExternalDoc|AppRoute + factories; only the 4 link-building checks and
the API mapper touched .Link.
- Evolve HealthCheckResponseModel additively (/api/v1 frozen-additive): keep
deprecated string? Link (still populated), add Brief (the BriefMessage the mapper
was silently dropping) and nested Remediation {Kind, Target}. Kind is a mapped
string, not a wire enum.
- Make Mapper.GetStatus total: NotApplicable no longer throws (defensive; handler
still filters it). InternalsVisibleTo(ErsatzTV.Tests) added to unit-test totality.
- Fix 2 stale Blazor route links (media/trash -> /app/trash, search?query ->
/app/search); add AppRoute remediation to actionable checks that had none
(libraries / schedules / ffmpeg-profiles / settings).
SPA:
- DashboardScreen health panel renders remediation: AppRoute -> client-side nav
button, ExternalDoc -> new-tab anchor; detail text truncates with title-hover.
- Remove the dead "Open Classic UI" -> /system/health row from SettingsScreen
(a #91b leftover that just 302'd to /app); update its regression test.
Docs: decisions.md (#164), api-conventions.md (deprecate-in-place DTO evolution),
blazor-route-parity.md (Section 4 correction); v1.json/v1.d.ts/endpoint-index
regenerated.
fixes #164
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8f61ad6530 |
feat(385): per-channel overrides in auto-tune bulk-create
Auto-Tune DetailPanel backend (#385), additive half. The create request `AutoTunedChannelRequest` gains three optional per-channel fields, all backward-compatible (omit = PR1 behavior): - `templateId` — overrides the batch template per channel - `advanced` — reuses the manual Channel Builder's `CreateChannelFromLineupAdvancedOptionsRequest` verbatim (24-field override set, `advanced.X ?? template.X` stamp contract). Axis default fills `PlaybackOrder` only when the caller leaves it null. - `logo` — uploaded channel image, `Sanitized()` at the request boundary (#283 stored-XSS defense), forwarded to `CreateChannelFromLineup.Logo` Resolved per channel inside `CreateAutoTunedChannelsHandler.CreateOne`, so one channel's bad override still yields a per-channel Failed/Skipped without aborting the batch. Per-source rotation weights + query corrections are split out to #425 (they need a MultiCollection-of-per-source-SmartCollections redesign — #70's WeightedShuffle reads weights only off MultiCollection join rows, and an auto-tuned channel is one SmartCollection). Bug-initials/colour generated logo also deferred (needs persisted Channel state + FFmpeg-pipeline wiring). Tests: handler override-threading (per-channel wins, axis default preserved, no-override baseline) + request `ToCommand()` logo sanitization. OpenAPI trio regenerated. Docs: decisions.md, api-conventions.md, domain-model.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1df08e86bb |
ci: pre-push guard against pushing an uncommitted working-tree change (H13)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 33s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
A file in the pushed diff that still has uncommitted working-tree/index changes means the pushed commit doesn't match what you built/reviewed — the #416 index/worktree trap, where a --no-renames review fix lived only in the working tree and shipped without being committed (CI, the push, and the reviewer each saw a different tree). New fail-open pre-push hook blocks that precisely (only files in the branch's diff vs origin/main), after the H11 rebase check. Escape: ETV_ALLOW_DIRTY_PUSH=1. Documented in decisions.md. Refs #416 |
||
|
|
082124f706 |
docs(416): note shallow-checkout FETCH_HEAD/two-dot fix (ci-cd + decisions)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #416 |
||
|
|
92bb63b6da |
docs(416): document docs-only CI skip
ci-cd.md gains a 'Docs-only skip' section + triggers-table note; decisions.md records the decision. Cross-refs the separate PR-vs-main rerun redundancy (#420) and the within-run triple build (#398). Refs #416 |
||
|
|
caaae4cd00 |
docs(70): re-derive the stale-claim fix by grep instead of working the review's list
Round-3 review returned BLOCKED: must-fix (b) was not closed. It was right, and the root cause it named is the point of this commit — the previous correction "was scoped to the four sites the reviewer listed rather than re-derived by grep". Fixing the list is not fixing the class. That is the same failure as B1, where the gate covered the two writers already in hand and missed CreateChannelFromLineup. Re-grepped the behavior class instead. Three survivors, two of them missed and one freshly introduced by the correction itself: - CreateMultiCollectionHandler.cs — the create twin of a comment whose UPDATE twin I corrected and whose create twin I never opened. Present tense, and contradicted by two tests in this same PR. - decisions.md — corrected one line in that file and left its sibling. - MultiCollectionItemWeight.cs (and its decisions.md mirror) — the ceiling rationale still claimed unbounded weights overflow the sum. They cannot: EffectiveWeight clamps before every sum and CycleLength widens to long. The earlier pass pattern-matched on the word "filtered" and left the identical defect on the ceiling. The ceiling's real job is the floor's argument — a billion is not a share of airtime any more than 0 is — so it now says that, and credits the clamp with the arithmetic safety it actually provides. Also corrected the writer claim to the right predicate: not "two persisting writers" (Add*ToPlaylist and Trakt persist it too, hardcoded) but two writers that persist a CALLER-SUPPLIED order. The full set is now classified persists-caller-value / persists-hardcoded / in-memory, including Engine/PlaylistHelper, which the previous "two Preview handlers" phrasing missed. That bullet has been wrong three times in the same shape; it now says so, since a lesson that keeps being re-learned is worth recording as a pattern rather than a fact. The BOM check caught this commit re-adding a BOM to the one file patched with utf-8-sig — the same trap, an hour after writing it down. Stripped; the mechanical pre-push check is what makes that survivable. Core.Tests 566, ErsatzTV.Tests 1673, 0 failed. Format verify exit 0. decisions.md +90/-0 (append-only guard green). Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ccef0ca88a |
fix(70): test the weight gate; correct rationale my own clamp made false
Re-review of the fix commit returned MERGEABLE-WITH-NITS. It verified the gate is now complete by enumerating the writers itself (no fourth persisting writer) and proved B2's fix works by writing throwaway handler tests — which was also its point: the fix shipped with none. B2 was create and update silently DISAGREEING on the same input, and the fix re-established agreement with nothing pinning it. Both paths are now driven from one shared case list, plus an explicit test that create and update agree on every case — the per-path tests would both have passed while the two diverged, which is how the bug existed in the first place. Non-vacuity proven: inverting only the update path's validation fails 10 of 20 on a clean build (0 errors, so not a stale-dll pass), and the agreement test is among the failures. The rest is my own prose contradicting my own code. The commit that added EffectiveWeight removed the weight filter, then left four statements asserting a 0-weight source "is filtered out" — two of them authored by that same commit, including the stated justification for Minimum=1 in MultiCollectionItemWeight. A future agent could have read that and deleted the clamp or the floor as redundant; they are belt-and-braces and neither is. Corrected to describe what the code now does: the gate refuses input that means nothing on a share-of-airtime scale, the clamp protects rows predating the gate. Also corrected the writer count in the very bullet whose lesson is "grep every writer of the field": ReplaceBlockItems writes BlockItem.PlaybackOrder, not PlaylistItem.PlaybackOrder. There are TWO persisting writers of PlaylistItem's, and the correction itself had miscounted by conflating the two fields — so the lesson now says to grep each field separately. Core.Tests 565 passed, ErsatzTV.Tests 1673 passed, 0 failed. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c0da414a4c |
fix(70): close the review blockers — third playlist writer, weight bounds, overflow
Adversarial review of PR #402 returned BLOCKED. It could not break the WRR math or the stateless-restore claim (it probed restore across wraps at indices 12/13/20/37 — all held, and the clamp preserves a 1000:1 ratio exactly). What it broke was the perimeter. B1 — the validation gate had a hole, so the silent-drop bug shipped. CreateChannelFromLineup is a THIRD writer of PlaylistItem.PlaybackOrder; its own guard only covered MultiCollection entries, so a 2+ entry lineup of plain collections persisted WeightedShuffle straight through to PlaylistEnumerator's null-drop. My decisions.md claim that "the silent sites never see it" was false as written — corrected in place, with the lesson recorded: grep every writer of the field, the non-obvious composite handler is the one that gets missed. The Add*ToPlaylist handlers are safe only because they hardcode their order. B2 — Weight had no validation at all, and create/update disagreed on the same input. EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not set") but an UPDATE writes the 0 through — and a 0-weight source was filtered out of the rotation, deleting it from the channel silently. Exactly the failure this order is careful to avoid everywhere else. Now bounded 1..1000 by a shared MultiCollectionItemWeight used by both paths so they cannot drift, and clamped again in the enumerator for rows that predate the gate. B3 — Sum(weights) is checked arithmetic, so two int.MaxValue weights threw OverflowException from inside a playout build. Reachable through the API precisely because of B2. The ceiling fixes both; the sum also widens to long. M1 the lineup mirror now allows WeightedShuffle for multi collections, matching the PlayoutModeMustBeValid change it claims to mirror. M3 ScheduleAsGroup is documented as deliberately unread by this order. L1 MinimumDuration is computed over every source instead of the current rotation — under the clamp a rotation is a strict subset and is rebuilt each wrap, so caching over it went stale. L2 the retry guard keys off the rotation, not the raw collection count. N1 the tautological default test is gone: it built entities in C#, so it asserted the property initializer, not the migration — it could not have failed. Replaced with clamp, overflow, and cross-wrap restore cases (the property the review proved but found unpinned). H1 the two follow-ups the PR body claimed were "filed" did not exist. Now filed: #403 (silent dispatch-fallback hardening) and #404 (SPA weight UI, blocked-by #388). Core.Tests 565 passed, ErsatzTV.Tests 1643 passed, 0 failed. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
953481c177 |
docs(70): record the WeightedShuffle design and correct the ShuffleInOrder misreading
decisions.md entry covers why one enum value rather than two or a separate setting, why the weight lives on both multi-collection join entities with a DB default of 1, why it can only be applied on the ShuffleInOrder-shaped path (source identity is destroyed on the Shuffle path), why it is stateless, and why cross-engine exposure is closed by write-path validation instead of by changing five shipped fallbacks. The load-bearing part is the ShuffleInOrder clarification. Its balanced-shuffle padding reads as equalization, but the spacers emit nothing — one cycle plays every item once, so airtime stays proportional to collection size. It is anti-clumping, not fair-share. That was misread during this issue's own design pass and would have collapsed #70 to "already shipped"; the distinction is the whole justification for the feature, so it is now written down in both decisions.md and the domain-model glossary rather than left to be rediscovered. domain-model.md gains a Playback order row contrasting Shuffle / ShuffleInOrder / WeightedShuffle, and notes on the multi-collection row that the two join entities are mirrors. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9b3121f0c2 |
docs(72): record the channel-health API shape + mirror the lineup fault state
Same-PR docs obligations for #72: - decisions.md — why health is the raw `PlayoutCount` fact on the lean list DTO rather than a derived status enum or a new endpoint, and, more importantly, why empty-schedule / broken-source / auto-tuned-origin are deliberately NOT computed. The empty-schedule one is the trap worth writing down: it only understands Classic ProgramSchedule playouts, so a badge driven off it would silently lie on the other four schedule kinds. - domain-model.md — a glossary row for channel health, drawing the line against /channels/state's OnAir (runtime liveness, not "would play if tuned"). - design-system — mirror the "No playout" badge + Problems filter into the Channels prototype so design-system/ stays a faithful mirror of prod (design-sync #388). Nature Docs carries playouts: 0 so the fault state is visible in the canvas. Refs #72 |
||
|
|
469d725559 |
ci(406): apply the memory-swap rule to our own two sites; stop leaning on the peak reading [decisions-edit]
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m13s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the fix commits came back MERGEABLE with three findings worth acting on.
MEDIUM -- the PR documented a standing rule ("--memory without --memory-swap silently
grants 2x in swap") and then didn't apply it to the two sites this repo owns:
docker-build.yml's smoke container and scripts/migration-smoke.sh, both `--memory 2g` with
no --memory-swap. Pre-existing rather than a regression, but a rule you don't follow in
your own repo isn't a rule. The migration-smoke one matters most: it runs on the PROD host
in the release path, so a runaway migration should die against its cap rather than quietly
swap out the box serving media.
LOW -- and this is the important one: the docs leaned "peak 8305 MiB is probably mostly
reclaimable cache". An independent probe (full solution build, same CI image, shared
compilation off) measured peak 9457 MiB / anon 7134 MiB / file 421 MiB. ANON DOMINATED.
Having verified the *mechanism* (peak overstates because it counts page cache), I guessed
the *magnitude* in the direction I preferred -- the exact failure this entry criticises,
committed inside the entry criticising it. Corrected in ci-cd.md, decisions.md and on
server-management#604 (where the previous comment could have led to an unsafe 6g cap).
Consequences now recorded honestly: a 6g cap looks UNSAFE, #570's "6g proved too tight" is
the rule not an outlier, and #406's premise ("if this brings peak RSS well under 6 GiB the
whole budget loosens") is looking DEAD -- the 7134 MiB anon was measured with shared
compilation already off. The switches remain right; the looser budget they were meant to
buy does not follow.
NIT -- dropped the unverified claim that this also disables the Razor build server. The
UseRazorBuildServer -> UseSharedCompilation fallback is .NET 5-era; Razor has been an
in-process source generator since .NET 6, so there is likely no separate server to disable
on .NET 10. Unverified, zero impact, so it has no business in a doc arguing for
measurement over assumption.
[decisions-edit]: the touched docs/decisions.md lines were added by this PR's own earlier
commits, not settled entries on main -- net vs origin/main remains a pure insertion (0
deletions, verified). Also the sanctioned reason: the entry was factually wrong (see LOW).
Verified: both workflows parse; migration-smoke.sh passes bash -n; the parsed mysql option
string is `--memory=2g --memory-swap=2g --cpus=2`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
7859510c78 |
ci(406): report anon/file split — memory.peak counts page cache, not just RSS [decisions-edit]
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m35s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The peak-memory step from the previous commit worked on its first real CI run and immediately produced a number that would have led #604 to the WRONG decision: peak container memory: 8305 MiB (8708448256 bytes, from /sys/fs/cgroup/memory.peak) Read naively that says "the test job needs 8.1 GiB, so the 10g cap must stay and #406's 'the budget loosens' premise is dead". That reading is probably wrong, because `memory.peak` is the high-water mark of `memory.current`, which charges PAGE CACHE to the cgroup as well as anonymous memory. It is not peak RSS, and a build job does heavy NuGet/npm/obj/bin/coverage I/O. Proven on bumblebee -- a container with ZERO anonymous memory that merely reads an 800MB file: memory.peak : 826 MiB anon : 0 MiB file : 800 MiB This inverts the decision: page cache is RECLAIMED under a tighter cap, not OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay high. `anon` is what forces an OOM. So the step now prints the anon/file split and says, in the log and the docs, to size caps on anon rather than peak. Honest about what this still isn't: the split is read at end-of-job, so it is the composition then, not at the peak instant -- a job peaking mid-`dotnet test` and freeing after would under-report anon. Sampling a true peak-anon is ersatztv#412, filed with the options. Also recorded there and in ci-cd.md: there is NO pre-change baseline from this instrument (7.8 GB VBCSCompiler was measured host-wide across concurrent jobs, not inside one job container), so #406's premise is neither confirmed nor killed yet. What IS established: no persistent compiler server survives a build, and migrations is green with mysql capped at 2g with swap disabled. The irony is the point, and it is now written into decisions.md: this repo's CI perf work keeps stating numbers from plausibility rather than measurement (#390's "2-4min" apt-ffmpeg estimate; real 110s). Measuring the wrong quantity precisely is the same failure wearing a lab coat -- and it bit this very change, whose whole purpose was to replace an inherited number with a measured one. Verified: shellcheck clean; the real step body run inside a --memory=2g container under `bash -e -o pipefail` reads memory.peak, prints the split, writes the step summary, exits 0; and it degrades to a skip + exit 0 where no cgroup file exists. [decisions-edit]: the modified docs/decisions.md lines are ones THIS PR added in its own earlier commits, not a settled entry on main -- net vs origin/main is still a pure insertion (0 deletions, verified). It is also the sanctioned reason: the entry as written was factually wrong about what memory.peak measures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
04e3a5249d |
ci(406): disable swap on the capped mysql; fix peak-step placement and failure mode [decisions-edit]
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m43s
Review fixes from the cold adversarial pass on PR #411. Verdict was MERGEABLE, but one
HIGH is a genuine defect worth the round-trip.
[decisions-edit] rationale: the 12 "modified" lines in docs/decisions.md are lines THIS PR
added in its own previous commit (
|
||
|
|
e33205e8eb |
ci(406): disable persistent compiler servers, cap the mysql service, report peak RSS
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m22s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #406, server-management#604. Items 1 and 2 of #406 (item 3 landed in PR #407). Root cause of the 2026-07-17 bumblebee incident (load 340, 21 GiB swapped, prod media down until reboot) is CI-induced memory pressure, and the largest single consumer was Roslyn's persistent VBCSCompiler at 7.8 GB RSS. It is a compiler *server*: it outlives the build that started it to keep its heap warm for the next one. In CI there is no next build -- each job container is torn down at the end of the run -- so it is pure cost, and it is the actual reason each job needed a 10 GiB cap. 1. Disable the compiler servers via the workflow's top-level env (UseSharedCompilation, DOTNET_CLI_USE_MSBUILD_SERVER, MSBUILDDISABLENODEREUSE). MSBuild surfaces env vars as properties and only defaults UseSharedCompilation when empty, so the env var wins and every dotnet call is covered without touching each call site. Also set as ENV in the Dockerfile's SDK stage: the workflow env reaches runner-side jobs only, and the `build` job compiles inside `docker build` where it does not propagate -- that is the job server-management#570 measured pegging 5.999/6 GiB, so it is the one that most needs this. Build stage only; the final image is FROM runtime-base, so nothing lands in the shipped image. Verified locally: a default build leaves 1 VBCSCompiler alive, the same build under these vars leaves 0, and ErsatzTV.sln still builds clean (0 errors). Trade-off: csc runs per project instead of via the server, costing some build time. 2. Cap the mysql service (--memory=2g --cpus=2). A runner's container.options applies to the job container ONLY: a live migrations job reported HostConfig.Memory=10737418240 for the job and mem=0 nanocpus=0 for its mysql service, i.e. unbounded. 2g is sized on measurement -- mysql:8.4 with this exact env peaked at 543 MiB during init, 481 MiB idle (probed on bumblebee) -- not on inheritance. Also add a `Report peak container memory` step to the test job (cgroup memory.peak, always(), never fails a build). server-management#604 sizes both runners' caps on that number and it was inherited rather than measured; now every run reports it. Docs: ci-cd.md gains the compiler-server + services-cap sections and the lane table is corrected (PR #407 reverted the #390 lane move without updating it). decisions.md records all three, incl. the standing rule that a services: container never inherits a cap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0009607a09 |
feat(384): auto-tune DetailPanel content-source member read endpoint
GET /api/v1/channels/auto-tune/members lists the distinct content sources a proposed auto-tune channel's server-generated SmartCollection resolves to — parent shows for the TV axes (ItemCount = query-matching episodes), movies for the movie-genre axis — reusing the existing PagedLibraryBrowseItemsResponseModel (no new schema). The handler runs the server-owned AutoTuneAxisMap.GenerateQuery through ISearchIndex (client never sends Lucene, per #69 PR1) and rolls matching leaf items up to their distinct sources, mirroring GetSmartCollectionItems so the DetailPanel preview matches what the built playout will contain. Backend child of #383 (Auto-Tune DetailPanel milestone); read-only, cold review acceptable. Handler + controller tests (9 new). OpenAPI + endpoint-index regenerated; d.ts unchanged (reuses existing schema). Docs: api-conventions §5, decisions.md 2026-07-17, domain-model. fixes #384 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
62c15da490 |
docs(73): document seasonal / date-conditional scheduling (already implemented)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
fixes #73 #73's premise ("ErsatzTV has no native date-conditional scheduling today") is false. The predicate (IAlternateScheduleItem) is shipped on both engines, evaluated by AlternateScheduleSelector, reachable from the SPA, and already unit-tested — so the only real gap was discoverability, and it was a docs gap: the mechanism was documented as a mechanism, never as a task. - channels.md: task-shaped "Recipe: seasonal / holiday programming" for both engines, plus the gotchas — chiefly that blank StartYear/EndYear is what makes a range repeat every year (AlternateScheduleSelector.cs:32-40), that the override branch needs BOTH years, and that explicit years disable wrap-around detection. - domain-model.md: glossary row (mirrors the #77 "already exists" row). - decisions.md: entry recording the verdict and the rejected asks — notably that "prioritize collection X during a date range" is #70's weighting problem, not a second primitive built here while #70 is mid-flight. No production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |