f795db073107c97845e5b2b2b036ebbe0b14b9cb
468
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e450d4983c |
docs(plan): resolve IRemoteImageValidator layering (Core has no ImageSharp)
Core interface returns Task (throws-on-invalid); render path uses a static Infra method returning Image. Drops the GraphicsEngine threading. refs #525 |
||
|
|
c75db91b63 |
docs(plan): external-logo download-on-save implementation plan
10 tasks, TDD, no schema change. Extracts RemoteImageDecodeBudget (Core) + RemoteImageValidator (Infra), adds IRemoteLogoCacher, wires the 3 channel handlers + UploadArtworkHandler, a startup migration for existing URL rows, and the SPA preview/copy changes. refs #525 |
||
|
|
9f99156e88 |
docs(spec): resolve open questions — fold in upload budget check, keep fetcher namespace
refs #511 |
||
|
|
2557760476 |
docs(spec): external channel-logo URLs become download-on-save
Design for reversing #511's render-time fetch: an external logo URL is downloaded once at save time into the artwork cache (content-hash name, same as an upload), validated against #511's decode budgets, and stored so nothing downstream knows it came from a URL. Bad URL fails in the editor, not at 3am. No refresh button — re-add the URL. One-time startup migration converts existing URL rows; failures leave the row and warn. refs #511 #502 |
||
|
|
46ea8f0e92 |
docs(521): carry archive<->archive reciprocity gap into PR2 Task 6 (PR1 whole-branch review, non-blocking)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 22s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 1m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m44s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d58b373463 |
docs(520): record MemPalace issue-discovery vs queue-state boundary + closing-record-as-artifact (PR2 retrieval contract)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f93458c76c |
fix(521): whole-branch review — correct Gitea anchors, guard archive+demotion, reciprocal links, stale append-only refs, budget warning [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b1e7e08884 |
feat(521): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header + CI rewrite [decisions-edit]
Retire the line-level append-only mechanic (ersatztv#303 H9) in favor of the lifecycle validator built in Tasks 1-5. .claude/hooks/decisions-guard.sh is now a thin fail-open shim around scripts/decisions_validate.py; .husky/pre-commit calls it for the structural (working-tree) checks, .husky/commit-msg drops the old staged/[decisions-edit]-deletion block and keeps only the Co-Authored-By check. The Gitea decisions-guard job is renamed "decisions lifecycle" and now runs decisions_validate.py --base/--head (structural + body-diff + no-vanish) and build_decisions_catalog.py --check (active catalog drift), with actions/setup-python@v5 added since the bare `small` lane doesn't guarantee python3; the old 1800-line consolidation-floor step is removed (replaced by the validator's aggregate active-corpus budget). docs/decisions.md's header is rewritten from append-only to lifecycle framing (metadata schema, statuses, generated catalog, archive, same-PR supersession); [decisions-edit] is re-scoped (not removed) to rationale-prose edits/factual corrections only. docs/ci-cd.md's release ritual and hook/job descriptions are rewritten to match. Also fixes a pre-existing validator false-positive surfaced while sanity-checking against origin/main: Task 6's #303 H9/H3 split (commit d09be57e) renamed the archived record's heading away from the pre-split original, which the validator's heading-based relocation check reads as "removed without an archive copy." Restored the archived heading to match the original text (functionally unchanged — still status: superseded, same key) and updated the two prose cross-references (migration-map.md, release-ci-governance.md) that pointed at the old anchor. PR1 scope only (per brief): does NOT wire the kickoff-guard CI step or touch the kickoff/README/select-queue docs — that's Task 8/PR2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
417279c072 |
fix(521): split #303 H9/H3 bundle — H3 root-screenshot guard stays active, only H9 archived [decisions-edit]
The archived docs.append-only-guard record bundled the genuinely-superseded H9 (decisions.md append-only) with the independent, still-active H3 (root-screenshot pre-commit guard), relocating both into archive/ and burying an active decision in the history wing. Split into two records: docs.append-only-guard stays in archive/release-ci-governance.md, status: superseded, H3 content removed and replaced with a forward pointer; ci.root-screenshot-guard is a new status: active record in the active docs/decisions/release-ci-governance.md, with the H3 rationale paragraph lifted byte-identical from the original bundle. Updated migration-map.md (new row + a "Migration conventions" note: never relocate an active decision to archive/) and regenerated docs/decisions/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eada44deb1 |
feat(521): reconcile #390/#406 + #411 + append-only supersession; seed exemplars [decisions-edit]
Migrate #406 (ci.runner-placement) and #412 (ci.peak-anon-measurement) to the lifecycle schema as exemplars; #390 and #411 confirmed prose-only (no standalone record). Dogfood the append-only->lifecycle supersession: docs.append-only-guard moved to archive/, superseded by new active docs.decision-lifecycle. Stand up docs/decisions/archive/ + migration-map.md. Refs #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
458ab2111f |
fix(521): catalog _anchor collapses punctuation runs; single trailing newline
_anchor() mapped each space/hyphen to its own '-' without collapsing runs, so the standard heading separator " — " (space, em-dash, space) produced a double hyphen in every generated anchor. Since nearly every real decision record heading uses that separator, this made the catalog emit a dead link for essentially every row. Fix: after building the char list, collapse consecutive '-' into one and strip leading/trailing '-' via re.sub, matching how Goldmark/GitHub/Gitea sluggers behave. Also fixed main() writing an extra trailing newline (want already ends in "\n", then "+ \n" appended a second one) so docs/decisions/README.md now ends with exactly one trailing newline; --check still compares via .strip(). Added test_anchor_collapses_em_dash_and_keeps_underscore to pin the anchor behavior against the reported iptv.base_url case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
67619b2bf6 |
feat(521): active-catalog generator
Adds scripts/build_decisions_catalog.py, which renders docs/decisions/README.md as a compact table of only 'active' decision records (sorted by key), and its test scripts/tests/test_build_catalog.py. Supports --check for CI drift detection. No decision records are migrated yet, so the generated catalog is currently empty (banner + header only) — expected at this stage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
428ebc0c81 |
docs(520,521): record #642 MemPalace conventions + archive-placement/bounded-strip invariants
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
404ec95479 |
docs(520,521): fold Fable review — body-diff guard, two-PR split, key/backstop fixes
Refs #520 #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0637fa46ac |
docs(520,521): implementation plan — decision lifecycle + startup rewrite
Refs #520 #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2af3cd024e |
docs(520,521): design spec — decision lifecycle + retrieval-efficient startup
Design for the decision-knowledge lifecycle schema/catalog/validator (#521) and the #237-retirement / parallel-startup kickoff rewrite (#520), as one arc. Refs #520 #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
64de59376f |
Merge pull request 'fix(502): render the on-screen bug for external-URL channel logos' (#513) from fix/502-external-url-watermark into main
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 / 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 / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m49s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m47s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m8s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m2s
|
||
|
|
7248416bd6 |
docs(release): Komodo stack is jazz-media; no auto-update fallback
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Verified live while promoting v26.11.0. Two corrections: 1. `DeployStack media-servers` targets a DEAD stack. The Komodo stack name changed to `jazz-media` with the move to jazz; the compose PROJECT is still `media-servers` (which is what container labels show, so the labels don't catch this). A `media-servers` stack still exists on bumblebee in state `unhealthy` — the stopped migration leftovers — so the documented command silently deploys the wrong, dead thing. 2. There is no Global Auto Update fallback: `jazz-media` has auto_update=false (poll_for_updates=true only). Promotion is manual, full stop, and the 'don't cut a tag near the 03:00 run' caveat is obsolete. The pre-deploy safety chain is intact and jazz-aware (#635) — verified by reading the deploy's Pre Deploy stage: image-change trigger fired, 286M backup with integrity_check=ok plus off-box PBS, migration smoke PASS against the prod-copy, then only ersatztv recreated. |
||
|
|
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> |
||
|
|
a352d832ad |
docs(release): v26.11.0 headline is the QSV VA-API decode fix (#498) + unified logo bug (#67)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 33s
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 38s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
The release range grew after the host-correction PR landed: #67 and #498 both merged into main. #498 in particular flips a default (QsvPreferNativeDecoder is ON), so it leads the row rather than sitting in a fixes list. |
||
|
|
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). |
||
|
|
cdbb685d22 |
docs(498): fold Fable review fixes into plan + spec
- Blocker 1: VA-API decode to SOFTWARE frames (drop -hwaccel_output_format, new DecoderVaapiToSoftware) so the proven hwupload/vpp_qsv branch bridges to the QSV encoder — the naive hardware-surface path emits a bare vpp_qsv on VA-API frames and fails on ~all content. - Blocker 2: bool? domain property + != false coercion (DeinterlaceVideo pattern) so create-with-false actually persists false. - High 3: REST DTOs bool?=null + ?? true for /api/v1 additive-compat. - Medium 4: correct Task 4 test scaffolding (DefaultHardwareCapabilities). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
369ba3332e |
docs(498): implementation plan — VA-API decode on the QSV pipeline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
32593a2263 |
docs(498): design spec — VA-API decode on the QSV pipeline
Mirrors Jellyfin's default-on "prefer native decoder" hybrid via a new QsvPreferNativeDecoder profile boolean. Reverified against code before designing; records the rejected decode-family-enum alternative (Option C). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
893d4a6398 |
test(67): de-vacuify the template-stamping assertion; cover adopt-then-delete
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 1m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m1s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Whole-branch review found the stamping test was structurally vacuous: a bare foreach over ChannelTemplates.Where(IsSystem) passes with zero iterations, so the test would have stayed green if template seeding silently bailed out. Assert the collection is non-empty first. Proven non-vacuous by a negative control (forcing SeedChannelTemplates to bail makes exactly this test fail). Also cover the ACTUAL production sequence -- adopt an existing hand-made row, then delete it -- which the previous no-resurrect test did not exercise (it covered seed-then-delete). The marker is written on the adopt path too, so the deleted row must stay deleted. docs: note that a deleted preset degrades to no default rather than failing, and that the default applies to newly created channels, not retroactively. Refs #67 |
||
|
|
bb7f57d04d | docs(67): record the shared-preset decision, watermark seeding, and the additive DTO field | ||
|
|
71bb2c23f3 |
docs(67): revise plan+spec per independent review — fix toggle blocker, seed marker, real preview geometry
Fable review (cold, review-only) found a blocker in the plan's own code:
Task 5/6 selected the logo-bug preset by searching for the first
imageSource==='ChannelLogo' entry, but getWatermarks() sorts by name
(pickers.ts:14), so with a second logo-driven preset -- which this design
explicitly invites users to create -- the toggle would read OFF for a
logo-driven channel and REPOINT it on tick, from a control documented as a
pure reflection of stored state. Now resolved by id lookup, with the
tick-on target chosen by a shared findLogoBugWatermark() helper and pinned
by a two-preset regression test.
Also adopted:
- ConfigElement seed marker (watermark.channel_bug_seeded): ChannelWatermark
has no IsSystem flag and Initialize runs every startup, so a name-only
guard resurrected a deliberately deleted preset forever.
- Channel-editor preview now fetches the referenced preset's REAL geometry
via the existing GET /api/v1/watermarks/{id} instead of hardcoding the
seeded defaults -- which would have been wrong for exactly the users who
tuned theirs.
- The 're-save untouched leaves watermarkId unchanged' test the spec
promised and the plan had omitted, plus a create-path degrade test.
- Reversed the ChannelBuilder exclusion (operator decision): fresh installs
stamp the preset onto the templates the seed creates; existing installs
are untouched.
- External-URL logos never render a bug (File.Exists against a URL,
WatermarkSelector.cs:269-286) -- verified, filed as #502, preview no
longer promises it.
- Dropped Task 2's InternalsVisibleTo branch: already present
(ErsatzTV.Application.csproj:30-32).
Refs #67 #502
|
||
|
|
9ff5580d49 |
docs(67): implementation plan + spec correction (ChannelBuilder inherits from template, excluded)
Self-review against the spec caught that the spec named both SPA creation paths, but ChannelBuilder does not hardcode a watermark -- it inherits watermarkId from the selected ChannelTemplate. Defaulting it would mean mutating the seeded Standard/Music videos template rows, which the seed's name-guard deliberately skips on existing installs, so it would no-op on prod while changing fresh installs. Excluded in both documents with the reason recorded. Refs #67 |
||
|
|
afbd50e3f0 |
docs(67): design spec — unified logo/bug via shared ChannelLogo watermark preset
Recon showed ChannelWatermarkImageSource.ChannelLogo already resolves the channel's logo artwork at render time, and prod already runs that pattern by hand (43 channels -> one 'Channel Bug' preset). So #67 is productizing a proven mechanism, not building one: seed the preset idempotently in DbInitializer (adopting any existing same-name row untouched), default the SPA creation paths to it, and add a shared <BugPreview> that renders the resolved geometry in both the channel editor and WatermarksScreen. No schema change, no WatermarkSelector change. One additive DTO field (WatermarkResponseModel.imageSource) under the frozen-additive /api/v1. Refs #67 |
||
|
|
d6aec6de76 |
docs(release): prepare v26.11.0 promotion — correct the deploy host to jazz
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 34s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The media transcoders (Jellyfin, ersatztv, ersatztv-test) moved from bumblebee to jazz (192.168.1.29) on 2026-07-20, server-management#633. Our docs still sent the release operator to bumblebee for security-scan.sh and named it as the docker host, which would have scanned the wrong box. Also fixes the inverse error: `jazz` was an EARLIER name for the .99 host, so three pre-migration references meaning today's bumblebee read as jazz. Those are now bumblebee, and a Hosts table + name-reuse warning is added at the top of ci-cd.md so the next session resolves hostnames by IP rather than re-breaking it in either direction. Version table had drifted (stopped at v26.9.0) — adds v26.10.0 + v26.11.0. |
||
|
|
56dff2c49f |
docs(489): spec + implementation plan for Jellyfin mixed-content libraries (#490)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m16s
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 / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Design spec and bite-sized TDD implementation plan for #489, whose implementation landed in #493. Docs-only. Kept as the record of how the design was reached: that Jellyfin classifies mixed-library items server-side via includeItemTypes (so no inference is needed), that MediaItem is TPT keyed on LibraryPathId (so no migration is needed), that MediaKind is dispatch + presentation only, and why the feature is deliberately scoped to Jellyfin rather than local libraries. Also records the open risk the plan carried -- the music-video scanner's untraced reconciliation -- which #494 subsequently answered. Refs #489 Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
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> |
||
|
|
1eff0b39b7 |
fix(473): gate the cancellation filter on the caller's token
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
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 / 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 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m17s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Third review pass: MERGEABLE WITH NITS. Taking the one finding it asked for before merge, plus a doc nit. The cancellation filter added last commit had no token check, and it spans the whole Transcode body -- including every mediator send (ffprobe via CliWrap, media-server API calls, subtitle extraction, song-video generation). TaskCanceledException is also what HttpClient throws on its OWN timeout, so a real timeout in any of those was being downgraded from an ERROR with a stack trace to a routine "Terminating HLS session" Information line. Behaviour was unchanged (both arms return false) but the fault signal was lost, and this repo has been bitten before by "empty log != the event didn't happen". Now filters on cancellationToken.IsCancellationRequested, so only genuine caller cancellation is treated as a graceful teardown. Doc nit: the <exception> block said cancellation "is thrown"; it is only thrown when the token trips while the probe is in flight -- cancelling after it completes returns normally. Now says "may propagate". Declined the reviewer's optional suggestion to drain until a 0-return instead of reading exactly one byte: reading exactly one byte is what makes the guard safe BY CONSTRUCTION, since a server or proxy that answers 206 with a wider range than requested still cannot be drained unboundedly. 206-only was confirmed correct rather than extended to short 200s, since deciding "short" from Content-Length would reopen the unbounded path for a chunked or Content-Length-less response. Also records the operator's standing rule in the handoff lore: a lone `decisions.md append-only` red is a known infra flake -- do not investigate, rebase, amend or push to clear it; the operator reruns that job from the UI. I violated this earlier in this PR with a tidy-but-wrong "my entry is no longer at EOF" theory, and the rebase did not fix it -- the job went red again on a verified pure-append diff, which is the proof the red was never about the diff. 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> |