Compare commits

..
Author SHA1 Message Date
timothy b52c938888 Merge pull request 'docs(release): v26.12.0 headline — ErsatzTV MCP server (#58) + external-logo download (#525)' (#531) from release/v26.12.0-notes into main
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 CI Toolchain Image / Build & push CI image (push) Successful in 2m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m20s
Build ErsatzTV Image / decisions lifecycle (push) Failing after 13m1s
Build ErsatzTV Image / Docs update reminder (push) Failing after 14m13s
Build ErsatzTV Image / CI image pin matches docker/ci (push) Failing after 14m14s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m15s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m25s
2026-07-21 14:49:42 +00:00
timothyandClaude Opus 4.8 6148805e37 docs(release): v26.12.0 headline — ErsatzTV MCP server (#58) + external-logo download (#525)
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 lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 35s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:47:20 +02:00
timothy 2834605d63 Merge pull request 'fix(529): floor QSV extra hardware frames so an unthrottled read can't exhaust the pool' (#530) from fix/529-qsv-extra-hw-frames-floor into main
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 lifecycle (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 34s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 36s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-21 14:41:15 +00:00
timothy 11b78bfcbd fix(529): round-two review — cover the handlers with tests, validate in the SPA [decisions-edit]
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 25s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m50s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Second review returned MERGEABLE with one Medium and three Lows. Addressed all four:

- Medium: the save-time normalization had zero test coverage, so a later refactor
  dropping Math.Max would leave the suite green (the FFmpegState floor keeps the
  pipeline correct, hiding the regression until someone reads a stored 0 back).
  Added Create/Update_Should_Floor_QsvExtraHardwareFrames over 0, -8, 63, 64 and 128,
  plus Create_Should_Leave_Null_QsvExtraHardwareFrames_Null for the null-passthrough
  branch, following the existing QsvPreferNativeDecoder tests' seed/handle/re-read
  shape. Negative-controlled: reverting both handlers fails exactly 5.

- Low: the SPA `min` was cosmetic. Input does forward it to the DOM, but there is no
  <form> — save is an onClick gated only on validate(), which had no branch for this
  field, so a typed 10 submitted fine and was silently changed to 64 with a 200 and no
  message. validate() now rejects it client-side.

- Low: the warning fires at the top of SetAccelState, before we know whether the
  pipeline uploads at all, so a fully-hardware path could be told "using 64 instead"
  when nothing consumed either value. Reworded to "will use ... wherever frames are
  uploaded".

- Low: recorded in the decision entry that the save-time normalization is
  unconditional on hardwareAcceleration (a non-QSV profile's stored value moves too),
  and that a client PUTting 0 reads back 64 — a transform the OpenAPI description does
  not advertise.

Verified in production, not just asserted. Set prod's profile to 64 (operator-approved)
and drove the exposed pipeline myself via the troubleshooting playback API on an mpeg4
.avi, which forces software decode + hwupload:

  hwupload=extra_hw_frames=64,vpp_qsv=w=1875:h=1080   exit 0, speed 12.0x, 0 ENOMEM

Then the negative control on prod's own hardware, same command, only the pool differing:

  extra_hw_frames=64 -> exit 0,   8 segments, 0 ENOMEM
  extra_hw_frames=0  -> exit 244, 0 segments, 3 ENOMEM

which reproduces the six overnight production failures and confirms the fix.

Full suite green: 4095 .NET, 891 web.

Refs #350, #516, #519.
2026-07-21 16:21:18 +02:00
timothy b27c950943 fix(529): address review — decision schema, #350 forward pointer, warning log, save-time normalization [decisions-edit]
Cold adversarial review returned BLOCKED on the documentation half. Addressed:

- The new decision record carried no lifecycle metadata block, taking the repo from
  82/82 to 83/82 and making it invisible to the by-key catalog lookup that #521
  established the same day. Added key/status/since/supersedes/superseded-by
  (ffmpeg.qsv-extra-hw-frames-floor) and regenerated docs/decisions/README.md;
  decisions_validate.py now reports OK with no legacy-unmigrated notice.
- The entry claimed to correct the #350 record but left that record untouched, so the
  stale "the burst is bounded" claim stayed authoritative for anyone resolving
  ffmpeg.hls-cold-start-burst. Added a forward-pointing correction note there (hence
  the [decisions-edit] token on this commit).
- The floor was applied silently. QsvPipelineBuilder.SetAccelState now logs a warning
  naming both the configured and applied value, because raising a deliberately small
  pool costs additional surfaces (64 NV12 1080p surfaces is roughly 190 MiB, 760 MiB
  at 4K) on memory-constrained iGPUs.
- Narrowed an overstated claim in the entry: 1..63 are untested, not known-bad. We
  raise them because the risk is a channel serving nothing, not because asking for
  less is illegitimate. Recorded as a deliberate over-reach with a stated cost.
- Corrected a factual error: SubtitleScaleQsvFilter also formats extra_hw_frames but
  is dead code with no construction site, so it is NOT covered by the guard.
- Config-vs-behavior mismatch: Create/UpdateFFmpegProfileHandler now normalize on
  save so stored rows converge on what the pipeline runs, and the SPA field carries
  min=64 rather than defaulting the display to 0. Render-time flooring is kept as the
  net that fixes existing deployments with no migration; the remaining gap for
  un-resaved rows is recorded as an accepted residual.
- Tests strengthened: pinned to the literal measured 64 rather than to the constant
  (so lowering the floor cannot quietly satisfy them), added a negative-value case,
  added a deinterlace-upload case, and replaced the narrow ShouldNotContain with a
  regex asserting EVERY extra_hw_frames occurrence in the command is >= the minimum.

Negative control re-run against the strengthened tests: reverting the floor fails 5,
with the build verified succeeded first. Full suite green (4086 .NET, 891 web).

Review finding that needed no change: the "single point" claim was independently
verified — no bypass exists, every FFmpegState construction routes through
MaybeQsvExtraHardwareFrames.

Refs #350, #516, #519.
2026-07-21 16:06:35 +02:00
timothy bbd7356f81 fix(529): floor QSV extra hardware frames so an unthrottled read can't exhaust the pool
A stored qsvExtraHardwareFrames of 0 reached FFmpeg as hwupload=extra_hw_frames=0,
leaving the QSV upload pool no headroom for frames in flight through the filter graph.
Any input that is not throttled then exhausts it: the graph fails with -12 (Cannot
allocate memory), h264_qsv reports "Could not open encoder before EOF", and zero
segments are written.

Measured against the deployed FFmpeg 8.1.2, one real logged command, only the marked
tokens differing:

  readrate 1.05, no burst   + frames 0  -> exit 0,   14 segments
  readrate 1.05 + burst 2/4/8 + frames 0 -> exit 244, ENOMEM, 0 segments
  no readrate at all        + frames 0  -> exit 244, ENOMEM, 0 segments
  readrate 1.05 + burst 8   + frames 64 -> exit 0,   14 segments
  no readrate at all        + frames 64 -> exit 0,   14 segments

So the defect predates #350's cold-start burst: a work-ahead start takes no -readrate
and was already failing on a profile with 0. The burst removed the throttle on every
realtime session, turning an intermittent failure into a near-deterministic one, which
is how it surfaced. Input throttling was doing load-bearing allocation-bounding work
that nobody had written down -- which is why the FFmpeg-level benchmark in #350 and the
argument-generation tests in #516 were both green and neither could see it.

Fixed at FFmpegState.QsvExtraHardwareFrames, the single point every QSV upload site
reads, so one guard covers HardwareUploadFilter, HardwareUploadQsvFilter,
WatermarkHardwareUploadFilter, ScaleQsvFilter, DeinterlaceQsvFilter and
SubtitleScaleQsvFilter rather than six call sites that can drift apart. Values above
the floor are still honored.

Tests negative-controlled: reverting the floor fails exactly the three sub-minimum
cases, with the build verified succeeded first.

Refs #350, #516, #519.
2026-07-21 15:52:57 +02:00
timothy 6b1bd9cf4b Merge pull request 'feat(525): external channel-logo URLs download + cache at save time' (#528) from feat/525-external-logo-download-on-save into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions lifecycle (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / EF migration integrity (SQLite + MySql) (push) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 28s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 10m16s
2026-07-21 13:36:15 +00:00
timothy aa76994825 docs(525): correct logo-download rejection status to 422, not 400
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 24s
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 1m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Live-E2E of the write path (scripts/e2e-local.sh) confirmed the feature end to
end — good URL -> cached /iptv/logos/<hash>, M3U emits the cached URL, cached logo
serves back as PNG, rejected save leaves the prior logo intact — and caught that a
bad/non-image/oversized logo rejects as 422 (BaseError -> ValidationProblemDetails
via ToErrorResult), not 400. 400 remains model-binding-only. Corrected channels.md,
api-conventions.md, and the graphics.channel-logo-caching decision Rule + catalog.
2026-07-21 13:19:07 +02:00
timothy 8930972a0a fix(525): address final-review findings (migration host-crash guard, multi-error 400s)
- Medium-1: wrap ExternalLogoMigratorService.ExecuteAsync in try/catch — a DB
  exception (e.g. a channel deleted mid-migration -> DbUpdateConcurrencyException)
  no longer trips BackgroundServiceExceptionBehavior.StopHost and kills the app;
  it logs and self-heals on the next boot. Caller-cancel path handled separately.
- Low-2: CreateChannelHandler/UpdateChannelHandler validation failure now returns
  errors.Join() (all accumulated errors) not errors.Head (first only), restoring
  the repo-wide convention; regression test added.
- Low-4: corrected the Startup registration comment (migrator self-awaits
  WaitForDatabase; order is not load-bearing).

Final whole-branch review: MERGEABLE @ 6d5f6b24 (fable). Carried Minors adjudicated
acceptable-defer.
2026-07-21 13:18:48 +02:00
timothy 56a18cd5dc docs(525): record download-on-save decision; update channels + api-conventions
New lifecycle record graphics.channel-logo-caching (supersedes: none — narrows
for YAML image elements). Regenerated the active catalog. channels.md + api-
conventions.md updated for the new save-time download + 400s.
2026-07-21 13:18:48 +02:00
timothy 3fb2145e50 feat(525): SPA previews saved logos, drops stale external-URL copy 2026-07-21 13:13:21 +02:00
timothy b54c06b5ec feat(525): startup migration converts existing URL logo rows to cache 2026-07-21 13:13:21 +02:00
timothy 36375157ad feat(525): render path no longer fetches a URL logo; degrades to no bug 2026-07-21 13:13:21 +02:00
timothy a5b4783b13 feat(525): budget-check direct artwork uploads (close the upload gap) 2026-07-21 13:13:21 +02:00
timothyandClaude Opus 4.8 93188eeb5e feat(525): download external-url logo on channel create + create-from-lineup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:13:21 +02:00
timothy 1f21f0f30e feat(525): download external-url logo on channel update 2026-07-21 13:13:21 +02:00
timothy 5985bef577 feat(525): add RemoteLogoCacher (fetch + validate + cache a logo URL) 2026-07-21 13:13:21 +02:00
timothy 65a41dbf8d refactor(525): extract RemoteImageValidator; render path delegates to static 2026-07-21 13:13:21 +02:00
timothy 7ee436241a refactor(525): extract RemoteImageDecodeBudget from ImageElementBase 2026-07-21 13:13:21 +02:00
timothy 21a4e4d34e 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
2026-07-21 13:13:21 +02:00
timothy 185de69930 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
2026-07-21 13:13:21 +02:00
timothy f1e7ee17ee docs(spec): resolve open questions — fold in upload budget check, keep fetcher namespace
refs #511
2026-07-21 13:13:21 +02:00
timothy 44199d3dba 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
2026-07-21 13:13:21 +02:00
timothy 063017584d Merge pull request 'feat(520,521): full decision-corpus migration + parallel-orientation startup rewrite (PR2 of 2)' (#527) from feat/520-521-migration-kickoff into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions lifecycle (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13s
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) Successful in 27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m12s
2026-07-21 10:29:18 +00:00
timothyandClaude Opus 4.8 1b207db87c fix(520,521): clear stale migration-map + narrow convention-docs-session-start rule to the task-map protocol [decisions-edit]
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m22s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m19s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:04:35 +02:00
timothyandClaude Opus 4.8 fb6720ea27 fix(521): de-dup 6 overlapped records; guard duplicate metadata blocks; exclude retrieval-eval; complete eval bank [decisions-edit]
- Exclude docs/decisions/retrieval-eval.md from active decision parsing
  (_NON_DECISION_FILES); its `## N.` eval-question headings were being
  miscounted as 7 legacy-unmigrated records.
- Add decisions_lib.metadata_line_count() + a decisions_validate guard
  that fails a record with more than one `key:` metadata line, so a
  stacked-metadata-block migration bug (which the parser silently
  tolerated by reading only the first block) can't recur unnoticed.
  TDD: test_duplicate_metadata_block_fails / test_single_metadata_block_passes.
- De-duplicate the 6 docs/decisions.md records left with two stacked
  metadata blocks (scan.getoraddfolder-db-lookup #488,
  scan.musicvideo-reconciliation #494, scan.jellyfin-mixed-content-library
  #489, iptv.logo-drives-bug-preset #67, ffmpeg.qsv-decode-encode-split
  #498, ci.small-lane-git-only server-management#639), merging the union
  of Signals/paths/issues/Mechanics from both blocks and keeping the
  richer Rule wording; rationale prose untouched.
- Fill in the deferred Q6b row in docs/decisions/retrieval-eval.md now
  that startup.parallel-orientation is active in docs/decisions.md,
  scoring it as a real active-vs-superseded question against the
  archived docs.queue-state-gitea-tracker.
- Regenerate docs/decisions/README.md via build_decisions_catalog.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:54:52 +02:00
timothy 4ac18e06bc merge(520): parallel-orientation startup + #237 retirement [decisions-edit] 2026-07-21 11:49:32 +02:00
timothy 4a07dd4373 merge(521): retrieval-eval bank 2026-07-21 11:49:32 +02:00
timothyandClaude Opus 4.8 962dc2a31a feat(520): parallel orientation+selection startup; retire #237 as live state; #642 retrieval bullets [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:48:14 +02:00
timothyandClaude Opus 4.8 c3b2b4d4bd docs(521): retrieval-eval question bank (active-record selection + citation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:39:20 +02:00
timothyandClaude Opus 4.8 1e79f62402 feat(521): migrate final 11 scheduling/CI gap records; corpus fully migrated [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:33:40 +02:00
timothy dbf842f8fb merge(521): migrate decisions.md range C tail [decisions-edit] 2026-07-21 11:30:25 +02:00
timothy ad814fec40 merge(521): migrate decisions.md range B [decisions-edit] 2026-07-21 11:30:14 +02:00
timothy 4d6ab394cd merge(521): migrate decisions.md range A [decisions-edit] 2026-07-21 11:30:14 +02:00
timothy f66c9b5c4b merge(521): migrate 4 topic files [decisions-edit] 2026-07-21 11:30:14 +02:00
timothyandClaude Opus 4.8 2e34d9c47b feat(521): migrate decisions.md range B: #385 through #502 decision records to lifecycle schema [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:29:05 +02:00
timothyandClaude Opus 4.8 aae2c418ad feat(521): migrate decisions.md range A: through #340 decision records to lifecycle schema [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:27:41 +02:00
timothyandClaude Opus 4.8 63a6fe80fb feat(521): migrate topic-file decision records to lifecycle schema [decisions-edit]
Adds metadata blocks (key/status/since/supersedes/superseded-by + Rule/Signals/Mechanics)
to all 26 legacy-unmigrated ## records across optimistic-concurrency.md (7, concurrency.*),
api-auth-security.md (9, security.*), release-ci-governance.md (7 remaining legacy records,
release.*), and spa-modularization.md (3, spa.*). Heading text and rationale prose are
byte-identical; only metadata was inserted, per the key-table assignments.

security.session-auth-dual-credential (#295 PR1) gets a one-line cross-reference noting it
narrows the OIDC-inert sub-claim of security.blazor-removal-auth-posture (#206), per
orchestrator adjudication — both stay status: active with supersedes/superseded-by: none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:26:25 +02:00
timothyandClaude Opus 4.8 97062cc439 feat(521): migrate decisions.md range C: tail #488+/#350/#58/#511 decision records to lifecycle schema [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:25:50 +02:00
timothyandClaude Opus 4.8 ab2d80b47a fix(521): reciprocity covers archive<->archive pairs; skip topic-file Contents heading
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:14:00 +02:00
timothyandClaude Opus 4.8 7bdb83fb16 docs(520): carry #642 orientation bullets into PR2 plan (from a814dd16)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:11:01 +02:00
timothy f4bebd77b8 Merge pull request 'feat(521): decision-lifecycle machinery — keyed records, validator, generated catalog (PR1 of 2)' (#526) from feat/520-521-decision-lifecycle 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 / decisions lifecycle (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 / Build & test (.NET) (push) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9m33s
2026-07-21 09:08:51 +00:00
timothyandClaude Opus 4.8 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>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 bdcc59ff80 test(521): add non-vacuous append-smuggle regression test for _rationale bound
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 7b8ac751d7 fix(521): bound _rationale strip window; restore REQUIRED_META; fail-open _run; archive-placement invariant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 9266437d68 feat(521): decision lifecycle validator
Adds scripts/decisions_validate.py: lifecycle invariant checks (unique
active key, key format, reciprocal supersession, removed-without-archive,
rationale-rewrite-without-token, catalog staleness, corpus budget) plus
git-diff helpers for merge-base-based CI checks. Deviates from the task
brief in one spot: REQUIRED_META narrowed to (key, status) — the brief's
(key, status, since, supersedes, superseded_by) makes its own
test_clean_corpus_passes fail, since since/supersedes/superseded_by
default to None on bare Record() instances built without going through
the markdown parser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 2cd78aa0bf style(521): lint/type cleanup on decisions_lib parser + test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 ba39ca65ae feat(521): decision-record parser (decisions_lib)
Adds scripts/decisions_lib.py, the shared parser for ErsatzTV decision
records (docs/decisions.md + docs/decisions/*.md). Parses H2 sections
into Record dataclasses, distinguishing migrated records (visible
metadata block: key/status/since/supersedes/superseded-by + Rule/
Signals/Mechanics) from legacy-unmigrated ones with no metadata line.

scripts/ is now an importable package (scripts/__init__.py,
scripts/tests/__init__.py) so later tools can `import scripts.decisions_lib`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 02:58:58 +02:00
timothyandClaude Opus 4.8 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>
2026-07-21 02:58:58 +02:00
timothy be0e2b1f1f Merge pull request 'fix(511): bound remote graphics-engine image fetches (timeout, size cap, decode cap, redirects, pooling)' (#518) from fix/511-remote-image-hardening into main
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
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 / 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 / Functional E2E (curl contracts) (push) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m4s
2026-07-20 23:47:20 +00:00
timothy 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
2026-07-21 01:28:13 +02:00
timothy 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.
2026-07-21 01:04:25 +02:00
timothy 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.
2026-07-21 01:04:25 +02:00
timothy 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
2026-07-21 01:04:25 +02:00
timothy d97e1dece2 Merge pull request 'feat(#58): ErsatzTV.Mcp — read + cautious-write MCP server over /api/v1' (#517) from feat/58-mcp-v1 into main
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 / Build & test (.NET) (push) Successful in 11s
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 / Functional E2E (curl contracts) (push) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 13m18s
2026-07-20 23:02:36 +00:00
timothyandClaude Opus 4.8 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>
2026-07-21 00:42:00 +02:00
timothy 34d76095a2 Merge pull request 'fix(350): burst-read the first HLS segments so cold start isn't readrate-bound' (#516) from fix/350-coldstart-seek 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 27s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 25s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m24s
2026-07-20 22:33:39 +00:00
timothy 0e3b0ca309 test(350): harden the burst tests against silent regressions
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m54s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the fix commit flagged two ways the new tests could pass
vacuously in future: the burst assertion was a bare substring (satisfiable
by any input carrying the option) and the still-image test asserted only an
absence. Anchor the first on the input path plus an occurrence count, and
give the second a positive anchor.
2026-07-21 00:07:35 +02:00
timothy 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
2026-07-20 23:59:42 +02:00
timothy 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
2026-07-20 21:33:50 +00:00
timothy 99b1ccadcc Merge pull request 'docs(release): Komodo stack is jazz-media; no auto-update fallback' (#514) from docs/komodo-stack-name into main
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 / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 27s
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 & push image (amd64) (push) Successful in 8s
2026-07-20 21:07:09 +00:00
timothy 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.
2026-07-20 23:04:34 +02:00
timothy 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.
2026-07-20 23:00:27 +02:00
timothy 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
2026-07-20 23:00:27 +02:00
timothy 64414be1ec Merge pull request '#508: move the two docker build jobs off the small lane' (#509)
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 / 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 / Functional E2E (curl contracts) (push) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 35s
Build CI Toolchain Image / Build & push CI image (push) Successful in 2m6s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
ci(508): move both `docker build` jobs off the `small` runner lane

Fixes #508. `small` is now git-only (ci-image-pin, docs-reminder,
decisions-guard); both heavy `build` jobs (docker-build image push +
ci-image toolchain buildx) move to ubuntu-latest so the lane can widen
to 4 slots across two hosts while committing less RAM than its single
10 GiB slot did. #574's skip-task queueing can't recur (build keeps
needs: [test, migrations]). Runner-side half: server-management#639.
2026-07-20 20:56:19 +00:00
timothyandClaude Opus 4.8 5709bf5a2c ci(508): bump the CI toolchain pin to the image this branch published
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m50s
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 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Editing .gitea/workflows/ci-image.yml is itself a trigger path for ci-image.yml, so
the previous commit republished the toolchain image at its own sha. `ci-image-pin`
then correctly failed: the pin still named 07048b8 while
`git log -1 -- docker/ci .gitea/workflows/ci-image.yml` resolved to 4263cf79.

The image content is unchanged — that commit only moved the job's `runs-on:` — but
the pin's contract is "the pin equals the last commit that touched the image
sources", not "the bytes differ", so it has to move. Verified 4263cf7 is actually
published to the registry before pinning it; a pin that doesn't resolve to a real
image would break every `container:` job at once.

This is why the bump lands as a SEPARATE commit: the tag is the short sha of the
pushed branch tip, so a single commit could never contain its own sha. Splitting it
makes the branch self-consistent — the source-touching commit stays the last one to
touch those paths, and this one carries the matching pin.

refs #508

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:29:56 +02:00
timothyandClaude Opus 4.8 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>
2026-07-20 22:24:12 +02:00
timothy 601d855b25 Merge pull request 'docs(release): v26.11.0 headline is the QSV VA-API decode fix (#498) + unified logo bug (#67)' (#507) from release/v26.11.0-notes 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build CI Toolchain Image / Build & push CI image (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 19m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m53s
2026-07-20 20:22:04 +00:00
timothy 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.
2026-07-20 22:20:52 +02:00
timothy 36499a96e0 Merge pull request 'feat(498): QSV profiles can decode via VA-API (Prefer native decoder)' (#506) from feat/498-qsv-native-decode 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 / decisions.md append-only (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 / Build & push image (amd64) (push) Has been cancelled
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
2026-07-20 20:16:36 +00:00
timothyandClaude Opus 4.8 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>
2026-07-20 21:54:13 +02:00
timothyandClaude Opus 4.8 264b17516d style(498): normalize pre-existing whitespace in touched files (#311 fix-as-you-touch)
Whitespace-only (git diff -w is empty); the #311 format job checks whole
touched files, and these two legacy files carried pre-existing violations
never caught before (no PR had touched them since the gate landed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:54:13 +02:00
timothy ed2bce4994 feat(498): add 'Prefer native decoder' checkbox to QSV FFmpeg profiles 2026-07-20 21:54:13 +02:00
timothy 9cbd1fdb5c chore(498): regenerate OpenAPI + SPA types for QsvPreferNativeDecoder 2026-07-20 21:54:13 +02:00
timothy be27d9ab8d feat(498): carry QsvPreferNativeDecoder through application + REST layer 2026-07-20 21:54:13 +02:00
timothy 993b6fd00c feat(498): QSV pipeline decodes via VA-API to software when preferring native decoder 2026-07-20 21:54:13 +02:00
timothy 5edc45ab76 feat(498): pass QsvPreferNativeDecoder from profile into FFmpegState 2026-07-20 21:54:13 +02:00
timothy 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).
2026-07-20 21:54:13 +02:00
timothy 626d5e7365 feat(498): QsvHardwareAccelerationOption emits VA-API decode when preferring native decoder 2026-07-20 21:53:16 +02:00
timothy 46fdbd282f feat(498): thread QsvPreferNativeDecoder through FFmpegState 2026-07-20 21:53:16 +02:00
timothy 381a1c2029 feat(498): add QsvPreferNativeDecoder domain field + migration (default on) 2026-07-20 21:53:16 +02:00
timothyandClaude Opus 4.8 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>
2026-07-20 21:53:16 +02:00
timothyandClaude Opus 4.8 369ba3332e docs(498): implementation plan — VA-API decode on the QSV pipeline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:53:16 +02:00
timothyandClaude Opus 4.8 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>
2026-07-20 21:53:16 +02:00
timothy 72dc1c9fba Merge pull request 'feat(67): one logo drives both the listing and the on-screen bug' (#504) from feat/67-unified-logo-bug 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 22s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 22s
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 & push image (amd64) (push) Successful in 5m57s
2026-07-20 19:36:43 +00:00
timothy 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
2026-07-20 21:13:47 +02:00
timothy 5d0a70a20f merge(67): new channels default to the logo-bug preset (T6) 2026-07-20 21:06:21 +02:00
timothy c1bd8c067a feat(67): default new channels to the logo-bug watermark preset 2026-07-20 21:05:38 +02:00
timothy 0cad333653 merge(67): channel editor logo-bug toggle + preview (T5) 2026-07-20 21:03:04 +02:00
timothyandClaude Opus 4.8 2176b73e23 fix(67): cover the bug-preview geometry path, preserve a custom logo preset across toggling
- Add ChannelEditScreen tests exercising the geometry-fetch/preview path (previously
  untested because the fixture's blank logo path kept BugPreview from ever rendering):
  asserts the fetched watermark geometry lands on the preview image with asymmetric
  values, and that an external logo URL suppresses the preview.
- Fix a toggle-off/toggle-on data loss: re-enabling "use logo as on-screen bug" used to
  always rebind to the default 'Channel Bug' preset, discarding a channel's own
  per-geometry ChannelLogo preset. Remember the last referenced logo-driven watermark id
  in a ref and prefer it, falling back to the shared default.
- Strip the geometry cache's `id` key before it reaches BugPreview's props (was leaking
  via a spread).
- Drop the "(see #502)" issue reference from the visible help text; keep it in the code
  comment.
- Constrain findLogoBugWatermark's imageSource param to the generated
  ChannelWatermarkImageSource union instead of `string`, and add unit tests for its
  preference/fallback/none-found behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:02:16 +02:00
timothy d5da504651 feat(67): add Use logo as on-screen bug toggle with preview to the channel editor 2026-07-20 20:51:58 +02:00
timothy e847c9eef3 merge(67): watermarks screen uses BugPreview (T4) 2026-07-20 20:50:00 +02:00
timothy defe76b551 merge(67): documentation (T7) 2026-07-20 20:48:59 +02:00
timothy bb7f57d04d docs(67): record the shared-preset decision, watermark seeding, and the additive DTO field 2026-07-20 20:48:14 +02:00
timothyandClaude Opus 4.8 5d103ca6ff feat(67): show resolved bug geometry on the watermarks screen
Replace the raw <img> preview in the Watermarks editor's Image row with
the shared BugPreview component, so the on-screen bug's location/size/
margins/opacity render the same way as the other three preview spots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:47:25 +02:00
timothy b0c06c6e7c merge(67): seed the shared Channel Bug watermark preset (T1) 2026-07-20 20:46:56 +02:00
timothy 520b113d66 merge(67): additive imageSource on the watermark picker DTO (T2) 2026-07-20 20:44:42 +02:00
timothy afd68b18fc merge(67): BugPreview component (T3) 2026-07-20 20:44:42 +02:00
timothy 4aeaecb1b2 feat(67): seed shared Channel Bug watermark preset, once per database 2026-07-20 20:43:27 +02:00
timothy ebaadc0656 feat(67): add imageSource to the watermark picker DTO (additive)
WatermarkResponseModel gains ImageSource so a client can identify
logo-driven presets generically instead of matching a user-editable name.
Additive under the frozen-additive /api/v1 contract (#286).

Adding a positional record parameter is source-breaking for existing
constructor call sites, so the two test files that built the DTO
positionally are updated. WatermarkHandlerTests now seeds its two rows with
DIFFERENT image sources so the round-trip assertion proves the field is
actually carried through the mapper rather than matching a constant on both.

Regenerated v1.json, endpoint-index.md and v1.d.ts; check:api clean.
Stripped the inherited UTF-8 BOM from Mapper.cs (#311 fix-as-you-touch).

Refs #67
2026-07-20 20:39:12 +02:00
timothy 5884bb2768 test(67): cover the remaining 4 bug-preview locations; note the MiddleCenter divergence (#503) 2026-07-20 20:23:38 +02:00
timothy 9ed933956b feat(67): add shared BugPreview component with pure geometry helper
Extracts on-screen bug (watermark) placement math into a pure,
DOM-free bugPreviewStyle(geometry) function plus a BugPreview
component that renders it inside a 16:9 frame. Consumed by the
channel and playout watermark editor screens (tasks 4/5) so users
can preview bug placement before saving.
2026-07-20 20:12:49 +02:00
timothy 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
2026-07-20 20:04:56 +02:00
timothy 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
2026-07-20 19:48:55 +02:00
timothy 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
2026-07-20 19:39:37 +02:00
timothy d7d53f7ffe Merge pull request 'docs(release): prepare v26.11.0 promotion — correct the deploy host to jazz' (#501) from release/v26.11.0 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 / 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 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6s
2026-07-20 17:19:30 +00:00
timothy 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.
2026-07-20 19:18:03 +02:00
timothy c4ad4a73ba Merge pull request 'fix(497): reconcile music-video metadata collections on Jellyfin rescan' (#499) from fix/497-musicvideo-metadata-update 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 / Build & test (.NET) (push) Successful in 7m15s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m50s
2026-07-20 16:53:41 +00:00
timothyandtimothy 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>
2026-07-20 16:36:02 +00:00
timothyandtimothy 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>
2026-07-20 16:34:51 +00:00
timothyandClaude Opus 4.8 c633b89ad9 fix(497): reconcile music-video metadata collections on Jellyfin rescan
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 1m12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
JellyfinMusicVideoLibraryScanner.UpdateMetadata copied only scalar fields
for an EXISTING music video, so genres/tags/studios/artists edited in
Jellyfin never reached ErsatzTV — the update path silently dropped every
child collection (only the Add path ever persisted them). Root cause is
inherited from upstream: unlike the movie/episode Jellyfin path, which
reconciles collections inside the tracked repository GetOrAdd,
MusicVideoRepository.GetOrAdd is AsNoTracking and the scanner never
reconciled the collections itself.

Fix mirrors PlexMovieLibraryScanner.UpdateMetadata's remove-stale +
add-new idiom, reconciling exactly the collections that BOTH the Add path
persists AND GetOrAdd eager-loads: Genres, Tags, Studios, Artists. Guids
(add-persisted but not eager-loaded — would duplicate) and Directors
(eager-loaded but not add-persisted for music videos) are deliberately
out of scope.

Movie/Episode paths do NOT have this gap (they reconcile in the tracked
repo GetOrAdd), so no separate fix is needed there.

Test is an interaction test (substituted repos, canned existing item)
verifying the exact reconcile calls; proven non-vacuous. The real-DB
double-scan approach can't drive this: the in-memory harness shares one
SQLite connection across contexts and mid-scan GetOrAdd's First()-nav
path predicate mis-resolves once the existing item carries metadata
children — a harness-only quirk (prod uses per-context pooled
connections).

fixes #497

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:32:29 +02:00
timothyandtimothy 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>
2026-07-20 15:22:15 +00:00
timothy a696f06d57 Merge pull request 'fix(488): resolve LibraryFolder from DB so Jellyfin music-video scans stop crashing' (#492) from fix/488-getoraddfolder-null-libraryfolders into main
Build ErsatzTV Image / CI image pin matches docker/ci (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 / Docs update reminder (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 39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 42s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 42s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m28s
2026-07-20 14:09:00 +00:00
timothyandClaude Opus 4.8 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>
2026-07-20 01:16:16 +02:00
timothy 3c5f4e867a Merge pull request 'fix(480): probe external-JSON remote-stream URLs before ffmpeg' (#486) from fix/480-external-json-probe 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 / decisions.md append-only (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 / Build & test (.NET) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m49s
Renovate / Renovate (push) Failing after 14m57s
Build CI Toolchain Image / Build & push CI image (push) Successful in 1m20s
Dependency vulnerability scan / NuGet vulnerable packages (push) Successful in 2m15s
2026-07-19 22:37:29 +00:00
timothyandClaude Opus 4.8 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>
2026-07-20 00:17:15 +02:00
timothy ab25658643 Merge pull request 'feat(472): sub-split the HLS cold-start startup phase' (#482) from feat/472-startup-subsplit 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 / decisions.md append-only (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 / Build & test (.NET) (push) Successful in 7m37s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m15s
2026-07-19 21:59:22 +00:00
timothy 55be64bd77 Merge pull request 'fix(477): guard media-server library sweeps against successful-but-empty fetches' (#483) from fix/477-scanner-antinuke-guard 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 24s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 32s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 35s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m24s
2026-07-19 21:53:37 +00:00
timothy 37674d6519 test(472): name the stale-playlist test for what it actually pins
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 46s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
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 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m22s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Third-round review caught that
Stale_Playlist_Guard_Should_Take_Precedence_Over_An_Otherwise_Valid_Three_Way
describes an impossible case: ThreeWay requires
processLaunched <= playlistExists, which is exactly the negation of the
guard condition, so the guard can never preempt a ThreeWay. What the
test really pins is precedence over the progress branches
(TwoWayLateProgress) — still the ordering that matters.

That is the same "rationale misstates the mechanism" defect the previous
commit existed to fix, landed inside the fix itself. Renaming rather
than leaving a test whose name teaches the next reader something false.

Also broadens the escape-hatch caveat: a stale playlist that slips past
the guard lands as TwoWay more often than ThreeWay, since FFmpeg has
usually not reported progress that early.

Test name and comments only; no logic change.
2026-07-19 23:33:28 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 23:28:27 +02:00
timothy 58b93d3e3d docs(472): correct the stale-playlist rationale; pin guard precedence
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 35s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the fix commit returned MERGEABLE with one Medium: the
comment justifying the stale-playlist guard misstated the mechanism, in
three places. It claimed Run "warns about a non-empty transcode folder
but does not delete it" — but StartFFmpegSessionHandler.FolderMustBeEmpty
calls EmptyFolder BEFORE the worker spawns, and Run's finally empties it
again. Verified directly rather than taken on the reviewer's word.

The real residual path is EmptyFolder FAILING: it swallows every
exception into a LogWarning and continues. Say that instead.

On a PR whose entire value is that the numbers mean what they say, a
rationale comment that misstates the mechanism is the same class of
defect the PR exists to prevent, so it does not get to ship as a nit.

Also documents that the guard is best-effort rather than a proof (if the
wipe failed, before-or-after-launch is a scheduling race, so an unlucky
sample can still slip through as an implausibly fast ThreeWay), and adds
the test the reviewer noted was missing: guard precedence over an
otherwise-valid ThreeWay.

Comments and one test only; no logic change.
2026-07-19 23:21:03 +02:00
timothy 757fb76151 fix(472): review fixes — honest bucket boundaries, stale-playlist guard
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 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 26s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
From the cold adversarial review of the initial diff. No blockers were
found; these address what the numbers MEAN, which is the whole point of
an instrumentation change.

- The buckets span the worker's Run entry, not the startup stopwatch, so
  prep overlaps the tail of `setup`. Rather than let the log imply an
  invariant it does not satisfy, say "spans runEntry" in the line, spell
  it out in the doc comment, and rename the test that had codified the
  false `sum == startup` claim.
- Guard `processLaunched > playlistExists` -> Unavailable: a stale
  live.m3u8 from a previous session (Run warns about a non-empty
  transcode folder but does not delete it) would otherwise yield a
  plausible-looking sample whose prep exceeds the measured phase.
- Split the two-way fallback into TwoWay vs TwoWayLateProgress. They are
  different stories about the pipeline and discriminating stories is
  what this issue is for.
- Document the 100ms playlist-poll quantization (it lands entirely in
  firstGop, the smallest bucket) and the first-process-failed case where
  ffmpegInit spans a retry.
- Short-circuit the per-line timestamp call; static readonly Unavailable.
- Tests for the new guard, progress-before-launch, and boundary equality
  (so tightening >= to > later cannot pass silently).
2026-07-19 23:05:14 +02:00
timothy d91d6ee1ed feat(472): sub-split the HLS cold-start startup phase
#350's measurement showed `startup` is 81% of tune-in latency and carries
100% of its variance, while remaining one opaque bucket spanning FFmpeg
spawn -> input open/probe -> encoder init -> first GOP. Two hypotheses
survive that measurement (NFS input open vs VAAPI init under contention)
and they need opposite fixes, so split before optimizing.

Adds `prep` (ErsatzTV-side work before FFmpeg exists) + `ffmpegInit`
(launch -> first `-progress` output) + `firstGop` (-> live.m3u8 exists)
to the existing Information-level cold-start line.

The pipeline runs `-loglevel error -nostats -hide_banner`, so a healthy
FFmpeg writes nothing to stderr; the `-progress` stream is the only
zero-cost milestone available and `ffmpegInit` therefore still lumps
input-open with encoder-init. That limit is documented rather than
papered over, and the split degrades to the two-way form #472 accepts
when no progress arrives before the playlist.

Log-only: no transcode behavior change, no new endpoint or config knob.

fixes #472
2026-07-19 22:49:07 +02:00
timothy de11eb01b3 Merge pull request 'fix(473): probe media-server remote streams before handing the URL to ffmpeg' (#479) from fix/473-missing-media-resilience 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 / Functional E2E (curl contracts) (push) Successful in 14m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m6s
2026-07-19 20:24:59 +00:00
timothy 0d70425572 Merge pull request 'fix(476): cascade FileNotFound from removed shows/seasons to descendants' (#481) from feat/476-cascade-filenotfound into main
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 / Build & test (.NET) (push) Successful in 14s
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 / Functional E2E (curl contracts) (push) Successful in 33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m9s
2026-07-19 20:19:37 +00:00
timothyandClaude Opus 4.8 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>
2026-07-19 22:02:15 +02:00
timothyandClaude Opus 4.8 61bd374494 fix(476): cascade FileNotFound from removed shows/seasons to descendants
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 24s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m52s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The media-server television scanner reconciles removed items at three levels
via existing.Except(incoming), but the season and episode sweeps live inside
the per-parent loops (ScanSeasons inside the show loop, ScanEpisodes inside the
season loop). Those loops only iterate parents present in the incoming list, so
a show (or season) that is gone from the media server is never visited and its
descendants are never swept — they keep their last state. On Jellyfin/Emby that
is RemoteOnly, which PlayoutBuilder does NOT skip even with PlayoutSkipMissingItems
on, so every orphaned episode keeps getting scheduled as a guaranteed tune-in
failure (the #473 reproduction; 717 stale prod rows across 10 removed shows).

Fix: cascade the flag by parent MediaItem.Id. Two provider-agnostic repo helpers
(Season.ShowId / Episode.SeasonId are on the base tables) flag descendants and the
scanner drives them after each parent sweep — show → seasons → episodes, and
season → episodes for the show-present case. Shared abstract base fixes
Jellyfin/Plex/Emby at once; Movie/OtherVideo are flat and have no such gap.

Tests: a Scanner.Tests case asserts the scanner cascades through the (substituted)
repository (non-vacuous — fails if the cascade calls are removed), and Integration
tests exercise the real cascade SQL against the schema, proving it flags only the
targeted subtree and no-ops on empty input.

fixes #476

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:49:33 +02:00
timothyandClaude Opus 4.8 dc5ceb5a14 fix(473): bound the drain, correct the interface contract, quiet graceful cancels
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
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 1m29s
Build ErsatzTV Image / decisions.md append-only (pull_request) Failing after 12m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Second review pass returned BLOCKED on two findings introduced by the
first fix commit. Both were right.

BLOCKER 1 — the drain added for "return the connection to the pool" was
unbounded. `response.Content.ReadAsByteArrayAsync()` buffers the WHOLE
body, and it ran for every non-404 response. A server that ignores
`Range: bytes=0-0` answers 200 with the entire file, so this would
download at line rate into a byte[] on the streaming hot path for up to
the 2s timeout -- strictly worse than the aborted socket it replaced, and
it defeated the ResponseHeadersRead the probe deliberately uses. Now the
single byte is read only on 206 (where the server honoured the range and
the body really is one byte); any other status aborts the socket, which
is much the cheaper evil. Two tests pin both directions; verified
non-vacuous (restoring the unbounded drain fails the 200-with-body test).

BLOCKER 2 — IRemoteStreamProber's doc-comment still described pre-fix
behaviour. I had told the reviewer it was updated; it was not -- only the
implementation's <remarks> had been. It claimed `false` on any 404 (now
only a redirected one) and that every other outcome returns `true` (caller
cancellation throws). Both clauses corrected, and the throwing contract is
now documented with <exception>.

Also fixed the reviewer's own follow-on finding: the cancellation rethrow
it asked for reached HlsSessionWorker's catch-all, which logs a
channel-level ERROR with a stack trace. The graceful
TaskCanceledException/OperationCanceledException handler at :662 wraps only
the inner ffmpeg block, not the mediator sends, so every client disconnect
on a remote-streaming channel would have produced a spurious ERROR -- in
exactly the logs a #350 cold-start investigation reads. Added a
cancellation filter on the outer try that logs Information instead.

Nit: stale SeedAll doc-comment now mentions the emby case.

Deferred, per reviewer's explicit agreement: Plex-branch handler coverage
(follow-up), and HEAD-with-GET-fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:47:50 +02:00
timothyandClaude Opus 4.8 a10c0325eb fix(473): compare parsed Uris in the redirect check
Defense in depth on the redirect detector: Uri.Equals compares normalized
components, so an escaping/casing difference can't be mistaken for a
redirect and fail CLOSED -- the exact failure the check exists to prevent.
A plex key can contain spaces or unicode.

Honest note: this is NOT a fix for an observed bug. I wrote a test claiming
to pin it, then ran the negative control and the test passed against the
string comparison too -- Uri.ToString() unescapes, so both forms agree for
our machine-generated URLs. The test was vacuous as written. It is kept,
retitled and re-commented to describe what it actually guards (an
un-redirected 404 on an escaping-sensitive url fails open), and the code
comment says plainly that this is defense in depth rather than a repair.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:42:21 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 21:42:20 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 21:41:55 +02:00
timothy 48d7598bae Merge pull request 'ci(412): sample true peak-anon in the test job, not cache-inflated memory.peak' (#475) from ci/412-peak-anon-sampler 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 27s
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 / Functional E2E (curl contracts) (push) Successful in 22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m56s
2026-07-19 19:09:29 +00:00
timothy 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
2026-07-19 20:49:08 +02:00
timothy 48d632d50a Merge pull request 'perf(469): format gate uses dotnet format whitespace --folder (~480s → ~0.5s)' (#471) from ci/469-format-speed into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (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) Successful in 33s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 35s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m4s
2026-07-19 17:59:33 +00:00
timothyandClaude Opus 4.8 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>
2026-07-19 19:34:43 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 18:33:31 +02:00
timothy e8d730359a Merge pull request 'test(444): deterministic functional-E2E for the playout-build lock 409 + isLocked projection' (#470) from feat/444-buildlock-e2e into main
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 / 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) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m43s
2026-07-19 15:57:41 +00:00
timothyandClaude Opus 4.8 038703fe67 test(444): deterministic functional-E2E for the playout-build lock 409 + isLocked projection
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 33s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m25s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds "Flow C" to scripts/e2e-functional.sh, the last deferred lock-contention flow from #363.
A playout build is enqueued onto the single-consumer WorkerService channel and the trigger
returns before BuildPlayoutHandler acquires the lock, so an accepted trigger does not prove the
lock is held. Flow C makes it deterministic: seed a Classic Flood schedule over a few short
ffmpeg episodes, crank PlayoutDaysToBuild=5 (~43k items ~= ~1s build), then POLL GET
/playouts/{id} until isLocked:true before firing. Asserts PUT /playouts/{id} -> 409, reset ->
409, and the list-projection isLocked:true while locked; then isLocked:false + PUT -> 200 after
the build (proving the 409 is lock-specific). Each racing assertion is guarded so a build that
finishes mid-flight degrades to an advisory skip, never a false red; the whole flow self-skips
without ffmpeg or if the build is never observed locked.

Sized by measurement on a fresh instance -- going wider is counter-productive (a 777k-item build
saturates the single worker with post-build gap/overlap jobs). Verified green across 6
fresh-instance runs; cold adversarial review MERGEABLE.

Docs: docs/e2e-local.md + docs/ci-cd.md updated to describe Flow C and drop it from the
"deferred" lists.

fixes #444

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:37:50 +02:00
timothy 481a14675f Merge pull request 'feat(431): TTL-cache health-check results; ?refresh=true forces a fresh run' (#468) from feat/431-health-ttl-cache into main
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 / Docs update reminder (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 / Build & test (.NET) (push) Successful in 34s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 33s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m37s
2026-07-19 13:43:11 +00:00
timothyandClaude Opus 4.8 ca54853b60 style(431): dotnet format the touched troubleshooting handler (#311 fix-as-you-touch)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m0s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m46s
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 37m33s
The Formatting CI gate flagged legacy stacked-foreach whitespace in
GetTroubleshootingInfoHandler.cs (lines ~121-137) — pulled into scope
because this PR touches the file (fix-as-you-touch, ersatztv#311). Pure
`dotnet format` reindent, zero logic change (git diff -w is empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:03:48 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 14:41:12 +02:00
timothy 67dc9ca4c0 Merge pull request 'fix(464): render real channel logos in guide grid + channels list' (#467) from fix/464-guide-logos 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 / decisions.md append-only (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 / Build & test (.NET) (push) Successful in 7m28s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m20s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m42s
2026-07-19 12:10:28 +00:00
timothyandClaude Opus 4.8 1aa1f0cf5b fix(464): render real channel logos in guide grid + channels list
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m33s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m45s
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 36m5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
The guide/EPG grid (/app/guide) and the channels list (/app/channels) always
drew the generated initials "bug" because the browse DTOs never carried a logo
URL — GuideScreen/ChannelsScreen rendered <ChannelLogo> with no src. The logo
data existed (it round-trips through the channel editor) but never reached these
views.

Add a rooted, directly-usable Logo URL to ChannelGuideChannelResponseModel and
ChannelResponseModel, populated by a single Channels.Mapper.GetLogoUrl helper
(#181 artwork convention): /iptv/logos/{file} for an uploaded logo, the absolute
URL passed through for an external one, null when unset so the SPA keeps its
generated-initials fallback. The guide query now includes Channel.Artwork.

Regenerated OpenAPI + v1.d.ts; updated api-conventions.md + domain-model.md.

fixes #464

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:28:00 +02:00
timothy a6c300730b Merge pull request 'docs(404): mirror the weight UI into the MultiCollections design prototype' (#466) from docs/404-designsync-mirror into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (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) Successful in 17s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m7s
2026-07-19 11:25:26 +00:00
timothyandClaude Opus 4.8 586b075cc1 docs(404): mirror the weight UI into the MultiCollections design prototype
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 1m18s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 41s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Design-sync back-mirror of the #404 weight UI (PR #462) into the Claude Design
prototype so repo + Claude Design stay byte-identical (docs/design-sync.md step 7).
Pushed to the ChicoryTV Design System project via DesignSync in the same session.

The prototype's EditItemRow now shows a per-source Weight input (1..1000) + computed
% share, and the editor gains a "Reset to fair share" footer — a faithful mockup of
the shipped screen. Prototype-only (design-system/), no shipped code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:05:07 +02:00
timothy cc2968de15 Merge pull request 'fix(463): bound the schedules "Active schedule" selector so it stops overlapping the header' (#465) from fix/463-schedule-select-width 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 / decisions.md append-only (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 / Build & test (.NET) (push) Successful in 7m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m57s
2026-07-19 10:21:00 +00:00
timothy 86cbf79b48 Merge pull request 'feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order' (#462) from feat/404-weight-ui 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 11s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 24s
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 & push image (amd64) (push) Successful in 5m13s
2026-07-19 10:01:41 +00:00
timothyandClaude Opus 4.8 df617a64c7 fix(463): bound the schedules "Active schedule" selector so it stops overlapping the header
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 14s
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 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The header selector rendered at the Select default `fullWidth=true`, which applies
`.ctv-field-full { width: 100% }`. In the flex `.ctv-schedule-header` that made it
demand the whole row, overlapping/distorting the title block and Add/Edit/Delete
buttons. Pass `fullWidth={false}` (sizes to content) + bound it to 150–260px so a
long schedule name can't re-widen it; the native select's value truncates within
the frame (`.ctv-select { min-width: 0 }`).

Pure-SPA/CSS, no API/DB change. Regression test asserts the selector wrapper is not
`.ctv-field-full`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:58:22 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 11:39:29 +02:00
timothyandClaude Opus 4.8 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>
2026-07-19 11:28:12 +02:00
timothy 996c545161 Merge pull request 'feat(queue): deterministic select-queue.sh — stop re-deriving the selector by hand' (#461) from feat/select-queue-script 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 8s
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 & push image (amd64) (push) Successful in 4m25s
2026-07-19 08:46:18 +00:00
timothyandClaude Opus 4.8 708e602197 feat(queue): deterministic scripts/select-queue.sh — stop re-deriving the selector by hand
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (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 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m23s
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
The kickoff dispatches a cheap model to rank the backlog, and the lore then made the
orchestrator re-derive its mechanical claims (deps, milestone tiering, priority order,
in-progress state) because a small model kept getting them wrong. This pays that tax down:
the mechanical checks are now CODE — GET /dependencies exclusion, LOCAL
.milestone.state/review/priority tiering (never the no-op ?milestones= filter),
(tier,priority,issue#) ordering, in-progress/parked/PR exclusion — leaving only two
JUDGMENT flags (CLAIM?, UMBRELLA?) for a human/model to resolve.

- scripts/select-queue.sh: fail-open on no-creds/unreachable; ~1.3s; shellcheck clean;
  UMBRELLA? computed in the jq pass (no per-candidate body fetch).
- handoff kickoff: run the script FIRST; trust its deps/tiering/ordering, recheck only flags.
- handoff lore: the three 'cheap selector unreliable → re-derive by hand' bullets kept as the
  EVIDENCE for why the script exists; the prescription is redirected to 'run the script'.

Operator-requested this session: 'rather than have the lore make us redo the selector's work,
improve the selector.'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:09:57 +02:00
timothyandtimothy 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>
2026-07-18 19:39:25 +00:00
timothyandClaude Opus 4.8 2fff5fc05d Merge pull request 'feat(420): skip re-validating an already-green identical merge tree' (#455) from feat/398-420-ci-build-once 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 / decisions.md append-only (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 / Build & test (.NET) (push) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m56s
feat(420): skip re-validating an already-green identical merge tree (#455)

Standalone cross-run tree-identity skip. Build-once (#398) was measured and rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:14:27 +00:00
timothyandClaude Opus 4.8 dd55f00ed4 docs(420): sharpen the skip's honest hit-rate (rebase-before-merge) + cite live combined-status check
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 10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m18s
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 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review nits from the scoped #420 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 20:53:23 +02:00
timothy 922b1ef53c docs(398,420): document the #420 skip + record build-once measured-and-rejected 2026-07-18 20:53:23 +02:00
timothy eb12176fe1 ci(420): skip re-validating an already-green identical tree on merge-to-main 2026-07-18 20:52:44 +02:00
timothy 8152afead1 Merge pull request 'feat(403): make unsupported PlaybackOrder loud at build time + tripwire' (#457) from feat/403-loud-playbackorder 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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m7s
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 / Functional E2E (curl contracts) (push) Successful in 14m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m3s
2026-07-18 17:10:35 +00:00
timothyandClaude Opus 4.8 1d95cbfee6 fix(403): accurate Classic fallback message + engine-coverage tripwire
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
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 / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m1s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m21s
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 14m18s
Review follow-ups (cold review of PR #457):
- Marathon can reach PlayoutBuilder's default arm via `goto default` when
  its enumerator can't be built; the new warning claimed "Marathon is not
  supported by classic scheduling", which is false. Distinguish
  supported-but-failed-to-build (logs "could not build") from genuinely
  unsupported using PlaybackOrderSupport, which also makes Classic a
  runtime consumer of the matrix.
- PlaybackOrderSupportTests now asserts every SchedulingEngineKind has a
  matrix entry, so a new engine kind fails the test instead of throwing
  KeyNotFoundException at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:32:14 +02:00
timothyandtimothy ca87336600 fix(401): reject Mirror channel with playout (422) + repair dead else-branch (#456)
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) Successful in 7m20s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 18m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m3s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m29s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:27:26 +00:00
timothyandtimothy f62ff0eebc fix(327): validate playlist name on rename (reject empty/whitespace/too-long) (#454)
Build ErsatzTV Image / CI image pin matches docker/ci (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 / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (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
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:27:19 +00:00
timothyandClaude Opus 4.8 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>
2026-07-18 18:23:21 +02:00
timothyandtimothy b1521f9047 fix(409,447): LibrariesScreen 'Never scanned' label + stabilize flaky scan-poll test (#451)
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 / Functional E2E (curl contracts) (push) Has started running
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
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:18:45 +00:00
timothyandtimothy ebb9a40dc3 fix(410): log scan cancellation below ERROR (user-initiated, not a failure) (#453)
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 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
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:18:02 +00:00
timothyandtimothy ab8e5d7a91 ci(338): distinguish ZAP warning (exit 2) from failure (exit 1) in security-scan (#452)
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 / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (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
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:16:41 +00:00
timothyandtimothy 11ec07ee1b fix(367): correct Plex budget-exhausted guidance when no servers exist (#450)
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 / 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 / EF migration integrity (SQLite + MySql) (push) Successful in 6m10s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m38s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m18s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:15:37 +00:00
timothyandtimothy 6462c36983 fix(310): strip UTF-8 BOM from the legacy .cs files #269 touched (#449)
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 / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (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
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:15:35 +00:00
timothy 6e97664487 Merge pull request #448 (feat/396): collapsible sidebar + nav-group accordions
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 / Functional E2E (curl contracts) (push) Successful in 5m15s
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) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Fixes #396. Force-merged (operator-authorized): all real checks passed green on 8cef07a6 (Build & test, EF migration, Functional E2E, formatting, docs, API sync, decisions.md append-only); the red status was a cancelled redundant re-run.
2026-07-18 15:24:53 +00:00
timothyandClaude Opus 4.8 8cef07a673 fix(396): mark active route in a default-collapsed group; fix mobile brand bar
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 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Has been cancelled
Cold-review findings:
- #1 (CONFIRMED): deep-linking/reloading into a route inside a default-collapsed
  labeled group (e.g. /app/settings, /app/libraries) left the expanded sidebar
  with no active indicator, violating "active route marked in both states". Now
  the group that contains the active route is force-shown (and its header shows
  expanded) WITHOUT persisting — navigating away reverts to the stored preference.
  Regression test added.
- #2 (mobile): a persisted-collapsed state hid the brand logo+wordmark while the
  toggle is also hidden at <=980px, leaving an empty header. Restore the brand in
  the mobile query (collapse is desktop-only).

Verified live (Playwright): /app/settings deep-link shows Settings active +
aria-current, System auto-revealed, Media stays collapsed, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:43:33 +02:00
timothyandClaude Opus 4.8 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>
2026-07-18 16:43:33 +02:00
259 changed files with 52154 additions and 1018 deletions
+11 -53
View File
@@ -1,54 +1,12 @@
#!/usr/bin/env bash
# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or
# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new
# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so
# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit:
# put the literal token [decisions-edit] in the commit message to override.
#
# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point
# is to catch the accidental rewrite-history case, never to wedge a legitimate commit.
#
# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final
# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1)
# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting.
#
# Modes:
# staged <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
# range <base> <head> CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs
set -euo pipefail
FILE="docs/decisions.md"
mode="${1:-}"
case "$mode" in
staged)
deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(cat "${2:-/dev/null}" 2>/dev/null || true)
;;
range)
base="${2:-}"; head="${3:-}"
[ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open
deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true)
;;
*)
exit 0 # unknown mode -> fail-open
;;
esac
# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard).
deleted="${deleted:-0}"
case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac
[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow
# Explicit override for a documented factual fix.
if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then
exit 0
fi
{
echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)."
echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries."
echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message."
} >&2
exit 1
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 0
command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open
PYTHONPATH=. python3 scripts/decisions_validate.py
rc=$?
[ "$rc" -eq 1 ] && exit 1 # only a real validation failure blocks
exit 0 # crashes/other codes -> fail-open
+8 -3
View File
@@ -53,9 +53,14 @@ env:
jobs:
build:
name: Build & push CI image
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
# it *builds* the toolchain), same as docker-build.yml's `build` job.
runs-on: small
# Moved off `small` with docker-build.yml's `build` (server-management#639). Being
# "docker-only" made it look lightweight, but it is a full buildx of the .NET
# toolchain image — the heaviest thing that ran in that lane. `small` is now
# git-only and capped at 1g per job, which would OOM this build.
#
# Rare trigger (pushes touching docker/ci + a weekly cron), so it costs the
# ubuntu-latest lane almost nothing, and ci-runner (.127) runs no prod workload.
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+143 -146
View File
@@ -25,7 +25,7 @@ name: Build ErsatzTV Image
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
#
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
@@ -36,6 +36,14 @@ name: Build ErsatzTV Image
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
#
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact
# source was already validated in the PR run. Every heavy step in those three jobs additionally
# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on
# main, so the image is still built (from already-validated source) even when the skip fires.
on:
workflow_dispatch:
@@ -95,7 +103,7 @@ jobs:
name: Build & test (.NET)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -103,9 +111,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# only the test job's steps below need the working tree; git history/tags
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
fetch-depth: 2
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
@@ -113,9 +121,14 @@ jobs:
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -123,13 +136,13 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -137,45 +150,55 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
# "Report peak container memory" step below. continue-on-error + a fail-open script => this
# instrumentation never reddens a build. Why anon and not memory.peak: ersatztv#412 /
# scripts/ci-peak-anon.sh header / docs/ci-cd.md "CI build memory".
- name: Start peak-anon sampler (ersatztv#412)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
continue-on-error: true
run: scripts/ci-peak-anon.sh start
- name: Build
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
@@ -186,7 +209,7 @@ jobs:
# floor later"), so this step is purely informational — continue-on-error keeps a missing
# report or a transient tool-install failure from ever blocking a build.
- name: Coverage summary
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
continue-on-error: true
run: |
set -euo pipefail
@@ -210,77 +233,32 @@ jobs:
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
fi
# Memory of THIS job container, reported every run (ersatztv#406, server-management#604).
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
# rather than measured: the 10g cap traces back to server-management#570 observing the image
# build peg 5.999/6 GiB, which is a different job entirely.
# Memory of THIS job container, reported every run (ersatztv#406/#412, server-management#604).
# #604 sizes the runners' per-job caps on these numbers. The headline is the TRUE PEAK ANON
# sampled by the "Start peak-anon sampler" step above — NOT `memory.peak`, which is the
# high-water mark of memory.current and charges reclaimable page cache to the cgroup (a build
# job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak). Page cache is
# reclaimed under a tighter cap, not OOM-killed, so sizing a cap off `memory.peak` inverts the
# decision. peak anon is the OOM-forcing number. Full rationale + the bumblebee demo:
# scripts/ci-peak-anon.sh header and docs/ci-cd.md "CI build memory".
#
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `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 for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
# reading 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` (+ a little kernel/sock) is the part that actually forces an OOM.
#
# The split below is read at end-of-job, so it is the *current* composition rather than the
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
#
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
# sits after Coverage summary to include reportgenerator, the job's last real workload.
# cgroup v2 first, v1 fallback.
#
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
# Runs LAST on purpose (after Coverage summary / reportgenerator, the job's last real workload)
# and stops the sampler. `always()` so a failed Build/Test still gets a peak reading; the split
# is read here (end-of-job = composition then, not at the peak instant — that is exactly why the
# sampler exists). Skipped on docs-only/already-validated runs (nothing ran to measure).
- name: Report peak container memory
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
# the same guarantee the Coverage summary step above uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
# `always()` controls whether this step RUNS, not whether its failure fails the job. With
# `defaults.run.shell: bash` (`-e -o pipefail`) a stray non-zero here would redden a green
# test job, so `continue-on-error` makes it advisory — the same guarantee Coverage summary uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
continue-on-error: true
run: |
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
peak=""; src=""
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
done
if [ -z "$peak" ]; then
echo "No cgroup peak-memory file readable in this container -- skipping."
exit 0
fi
anon=""; file=""
if [ -r /sys/fs/cgroup/memory.stat ]; then
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
fi
echo "::group::Container memory (ersatztv#406 / server-management#604)"
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
if [ -n "$anon" ]; then
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
else
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
fi
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
"$(mib "$peak")"
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
"$(mib "$anon")" "$(mib "${file:-0}")"
printf '\n'
} >> "$GITHUB_STEP_SUMMARY" || true
fi
run: scripts/ci-peak-anon.sh report
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -339,17 +317,24 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
with:
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
fetch-depth: 2
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -357,11 +342,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
@@ -369,7 +354,7 @@ jobs:
# SQLite is the prod provider; both checks validated locally.
- name: SQLite — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
echo "::group::SQLite model drift (has-pending-model-changes)"
@@ -385,7 +370,7 @@ jobs:
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
env:
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
@@ -432,7 +417,7 @@ jobs:
# v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -440,15 +425,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
# comparison can resolve on a main merge commit.
fetch-depth: 2
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -456,11 +448,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -468,17 +460,17 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm ci
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run build
- name: Build (Release)
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
@@ -489,7 +481,7 @@ jobs:
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
- name: Boot instance and run functional-E2E harness
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
@@ -504,12 +496,20 @@ jobs:
build:
name: Build & push image (amd64)
# `small` = the dedicated small-jobs runner lane (server-management#574).
# On PR runs this job only resolves its skip, but Gitea still dispatches it
# as a task — on the ubuntu-latest runners that skip queued behind long
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
# capped at 4 CPUs / 10g.
runs-on: small
# Moved back off `small` (server-management#639). This is the one HEAVY job that
# was still in that lane, and its 10g requirement was what pinned the lane's
# per-job cap at 10g — which in turn capped the lane at ONE slot on a 25 GiB
# host. Four jobs sharing one slot is what starved the git-only checks in act's
# setup phase (>10 min, no logs, then fail). With this job gone, `small` is
# git-only and can run wide and tiny on two hosts.
#
# The `ubuntu-latest` queueing that sent it to `small` in the first place
# (server-management#574: a PR-run skip stuck 31 min behind long builds) does not
# come back, because `needs: [test, migrations]` means this job cannot be
# dispatched until those two have already finished — by which point the lane it
# was queueing behind has drained. Real builds (main/tags) get the full
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
runs-on: ubuntu-latest
needs: [test, migrations]
if: github.event_name != 'pull_request'
steps:
@@ -726,12 +726,14 @@ jobs:
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or
# rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries
# the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg
# hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners.
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
# vanishing from the active set without an archive copy) and that the generated active catalog
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
decisions-guard:
name: decisions.md append-only
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
steps:
@@ -739,22 +741,19 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enforce append-only
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD
- name: Consolidation-floor reminder (non-blocking)
run: |
# Consolidation is primarily a release step; this is the between-releases floor. The metric is
# the file's LINE COUNT — the context an agent actually burns reading the log — not entry count.
# Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's
# own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read.
n=$(wc -l < docs/decisions.md | tr -d ' ')
echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)."
if [ "${n:-0}" -gt 1800 ]; then
echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header."
fi
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
@@ -785,7 +784,7 @@ jobs:
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -858,19 +857,29 @@ jobs:
echo "Generated API artifacts are in sync."
# Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to
# .editorconfig (style + charset=utf-8, i.e. no UTF-8 BOM). Scoped to changed files so it enforces
# "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 pre-existing
# BOM files. A PR that touches no .cs skips the expensive steps and passes trivially (always reports
# a status, so it is safe as a required check).
# .editorconfig whitespace + charset=utf-8 (i.e. no UTF-8 BOM). Scoped to changed files so it
# enforces "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500
# pre-existing BOM files. A PR that touches no .cs skips the check and passes trivially (always
# reports a status, so it is safe as a required check).
#
# ersatztv#469: uses `dotnet format whitespace . --folder`, NOT the full `dotnet format <sln>`.
# `--folder` treats the tree as a plain folder of files and skips the MSBuild/Roslyn workspace load
# + per-project compilation that dominated the old recipe (~8 min locally on a whole-solution run) —
# `--include` only ever narrowed *which* files were checked, never what got loaded. Folder mode
# reads .editorconfig and still flags WHITESPACE (indent/EOL/trailing/final-newline) and CHARSET
# (BOM) violations — exactly what this gate exists to catch — in ~0.5s with no `dotnet restore`.
# What it drops is the style/analyzer pass (naming/`var`/qualification), which this gate never
# meaningfully enforced: those .editorconfig rules are :suggestion/:none severity. Full rationale +
# non-vacuity evidence: docs/ci-cd.md → Formatting; docs/decisions.md.
format:
name: Formatting (changed .cs conform to .editorconfig)
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
# Folder-mode whitespace is now a seconds-long, low-memory job (no Roslyn workspace, unlike the
# 3.95 GiB full `dotnet format` measured in #406), so it no longer needs the memory headroom that
# kept it on `ubuntu-latest`. Left here to avoid re-touching the lane/memory-cap accounting; a
# move to a lighter lane is a server-management capacity call (#604).
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -897,26 +906,14 @@ jobs:
echo "No .cs change -> skipping format verify (job passes)."
fi
- name: Cache NuGet packages
if: steps.detect.outputs.cs_changed == 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.cs_changed == 'true'
run: dotnet restore
- name: Verify formatting of changed .cs files
if: steps.detect.outputs.cs_changed == 'true'
shell: bash
run: |
mapfile -t files < /tmp/changed-cs.txt
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig..."
if ! dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"; then
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (formatting or a UTF-8 BOM). Run 'dotnet format ErsatzTV.sln --include <files>' and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig (whitespace + charset)..."
if ! dotnet format whitespace . --folder --verify-no-changes --include "${files[@]}"; then
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (whitespace or a UTF-8 BOM). Run 'dotnet format whitespace . --folder --include <files>' (or the full 'dotnet format ErsatzTV.sln --include <files>') and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
exit 1
fi
echo "All changed .cs files conform to .editorconfig."
-5
View File
@@ -8,8 +8,3 @@ grep -q '^Co-Authored-By:' "$1" || {
echo 'husky - commit message missing Co-Authored-By trailer'
exit 1
}
# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled
# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so
# the staged diff is what's being committed; the message file ($1) supplies the override token.
./.claude/hooks/decisions-guard.sh staged "$1" || exit 1
+14 -6
View File
@@ -1,6 +1,12 @@
cd web && npx lint-staged || exit 1
cd ..
# ersatztv#521 — decision-record lifecycle structural validator (replaces the old H9 append-only
# line guard). Runs the same validator the CI `decisions lifecycle` job uses, over the working
# tree (no base/head here, so only structural checks run; the body-diff/no-vanish checks run in
# CI where a base ref exists). Fail-open shim — see .claude/hooks/decisions-guard.sh.
./.claude/hooks/decisions-guard.sh || exit 1
# H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with
# .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets.
root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true)
@@ -11,15 +17,17 @@ if [ -n "$root_png" ]; then
exit 1
fi
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
# dotnet format on staged .cs files (repo root). Uses `whitespace . --folder` — same recipe as
# the CI `format` job (ersatztv#469): folder mode checks .editorconfig whitespace + charset (BOM)
# without the MSBuild/Roslyn workspace load, so it runs in ~0.5s instead of the old ~20-40s sln
# load. Keeping this identical to CI avoids a local hook that blocks on rules CI no longer enforces.
# Skip entirely when no .cs is staged (avoids any cost for web-only commits).
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
if [ -n "$cs_files" ]; then
echo "husky - dotnet format (verify) on staged .cs files"
echo "husky - dotnet format (whitespace verify) on staged .cs files"
# shellcheck disable=SC2086
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
dotnet format whitespace . --folder --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found whitespace/BOM issues in staged .cs files; run 'dotnet format whitespace . --folder --include <files>' to fix"
exit 1
}
fi
+19 -6
View File
@@ -35,10 +35,10 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Deployment
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee`/config` in container
- **Docker host**: **jazz (192.168.1.29)**, container `ersatztv`, port 8409. Media transcoders (Jellyfin, `ersatztv`, `ersatztv-test`) moved here from bumblebee on 2026-07-20 (server-management#633); bumblebee (192.168.1.99) still hosts the **CI runners** and the rest of the stacks. **Name-reuse trap**: `jazz` was an *earlier* name for the .99 host, so pre-2026-07-20 docs/commits saying "jazz" mean today's **bumblebee** — go by the IP, not the name.
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz`/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** stack — named **`jazz-media`** (the compose *project* is still `media-servers`; a dead `media-servers` stack lingers on bumblebee) — follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually `DeployStack jazz-media`. There is **no** auto-update fallback (`auto_update: false`) — promotion is manual. Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
@@ -56,7 +56,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read the `docs/README.md` **task-signal map** and only the sections it points to for your task — not the whole corpus. **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code. **Decision/convention lookups start at the active catalog**, `docs/decisions/README.md` — resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; see `docs/README.md` → "Knowledge retrieval").
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
@@ -64,7 +64,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
| Establish / reverse a convention or decision | `docs/decisions.md` (lifecycle: add record, relocate predecessor to archive/) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
@@ -91,11 +91,24 @@ Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pa
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds):
```markdown
## Closing record
**Outcome:** <what shipped / what didn't; PR link>
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
**Verification:** <tests run, live-E2E, CI status>
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
```
## Project Boundaries
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
@@ -9,8 +9,13 @@ namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
private readonly IImageCache _imageCache;
private readonly IRemoteImageValidator _validator;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public UploadArtworkHandler(IImageCache imageCache, IRemoteImageValidator validator)
{
_imageCache = imageCache;
_validator = validator;
}
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
@@ -38,6 +43,22 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
string contentType = maybeContentType.IfNone(string.Empty);
// One rule: anything entering the logo cache is decode-budget-checked. A supported format is
// not enough — a small header can declare a multi-gigabyte canvas (a decompression bomb), so
// reject it here before it lands in the cache. The synthetic upload:// Uri is only for the
// exception message text. (ersatztv#525)
using (var probe = new MemoryStream(bytes, writable: false))
{
try
{
await _validator.Validate(probe, new Uri("upload://artwork"), cancellationToken);
}
catch (Exception ex)
{
return BaseError.New($"Image cannot be used: {ex.Message}");
}
}
using var toCache = new MemoryStream(bytes, writable: false);
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
toCache,
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -21,6 +22,7 @@ public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
@@ -37,7 +39,42 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
Right: async prepared =>
{
Either<BaseError, PreparedCreate> resolved =
await ResolveExternalLogo(request, prepared, cancellationToken);
return await resolved.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: p => PersistAndDispatch(dbContext, p, cancellationToken));
});
}
// The lineup logo artwork is built (in BuildChannel) with the raw request path. When that path is
// an external http(s) URL, download + cache it and swap the cache name onto the logo artwork before
// persisting (a cacher Left fails the whole create); a blank or already-local/cached path is left
// unchanged. (ersatztv#525)
private async Task<Either<BaseError, PreparedCreate>> ResolveExternalLogo(
CreateChannelFromLineup request,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return prepared;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached.Map(name =>
{
foreach (Artwork logo in prepared.Channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
{
logo.Path = name;
}
return prepared;
});
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
@@ -1,9 +1,10 @@
using System.Globalization;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -16,7 +17,8 @@ namespace ErsatzTV.Application.Channels;
public class CreateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher)
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
{
public async Task<Either<BaseError, CreateChannelResult>> Handle(
@@ -25,7 +27,52 @@ public class CreateChannelHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => PersistChannel(dbContext, c));
return await validation.Match(
Succ: async channel =>
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath =>
{
ApplyResolvedLogo(request, channel, logoPath);
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
},
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, CreateChannelResult>(errors.Join())));
}
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
// downloaded and cached (a cacher Left fails the whole save); an empty path or an
// already-local/cached path passes through unchanged. (ersatztv#525)
private async Task<Either<BaseError, string>> ResolveLogoPath(
CreateChannel request,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return path;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached;
}
// When the incoming logo was an external URL, swap the downloaded cache name onto the logo
// artwork built during validation so no URL is ever persisted in Artwork.Path. (ersatztv#525)
private static void ApplyResolvedLogo(CreateChannel request, Channel channel, string resolvedLogoPath)
{
if (!Artwork.IsExternalUrl(request.Logo?.Path ?? string.Empty))
{
return;
}
foreach (Artwork logo in channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
{
logo.Path = resolvedLogoPath;
}
}
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
@@ -6,6 +6,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -19,7 +20,8 @@ namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher)
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
public async Task<Either<BaseError, ChannelViewModel>> Handle(
@@ -39,17 +41,45 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> validation =
await Validate(dbContext, request, channel, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
return await validation.Match(
Succ: async c =>
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath => Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
},
None: () => Task.FromResult(
Left<BaseError, ChannelViewModel>(
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
// downloaded and cached (a cacher Left fails the whole save); an empty path (logo removal) or an
// already-local/cached path passes through unchanged. (ersatztv#525)
private async Task<Either<BaseError, string>> ResolveLogoPath(
UpdateChannel request,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return path;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached;
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
UpdateChannel update,
string resolvedLogoPath,
CancellationToken cancellationToken)
{
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
@@ -76,9 +106,9 @@ public class UpdateChannelHandler(
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
if (!string.IsNullOrWhiteSpace(resolvedLogoPath))
{
string logo = update.Logo.Path;
string logo = resolvedLogoPath;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
+27 -1
View File
@@ -109,7 +109,8 @@ internal static class Mapper
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg,
playoutCount);
playoutCount,
GetLogoUrl(channel));
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -123,6 +124,31 @@ internal static class Mapper
channel.FFmpegProfile.VideoProfile,
channel.FFmpegProfile.AudioFormat);
// Rooted, directly-usable channel-logo URL for the SPA's <img src> on browse surfaces (guide grid +
// channels list), following the #181 artwork convention (docs/api-conventions.md §4): the SPA does no
// client-side path building. External logo URLs pass through as-is; an uploaded logo ("iptv/logos/{file}")
// is rooted with a leading slash so it resolves against the site root regardless of the current SPA route.
// Returns null when the channel has no logo, so the SPA falls back to the generated initials "bug".
#nullable enable
internal static string? GetLogoUrl(Channel channel)
{
// Browse surfaces must not crash the whole list over a missing Artwork include; GetLogo assumes
// the caller included Channel.Artwork (GetAll + the guide query do), but stay defensive here.
if (channel.Artwork is null)
{
return null;
}
ArtworkContentTypeModel logo = GetLogo(channel);
if (string.IsNullOrWhiteSpace(logo.Path))
{
return null;
}
return logo.IsExternalUrl || logo.Path.StartsWith('/') ? logo.Path : $"/{logo.Path}";
}
#nullable restore
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
Option<Artwork> maybeArtwork = channel.Artwork
@@ -47,6 +47,7 @@ public class GetChannelGuideDataHandler(
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.Include(c => c.Artwork)
.Include(c => c.MirrorSourceChannel)
.ToListAsync(cancellationToken);
@@ -121,6 +122,7 @@ public class GetChannelGuideDataHandler(
new ChannelGuideChannelResponseModel(
channel.Number,
channel.Name,
Mapper.GetLogoUrl(channel),
programmes.OrderBy(p => p.Start).ToList()));
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
@@ -34,4 +34,5 @@ public record CreateFFmpegProfile(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -1,7 +1,8 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -67,7 +68,11 @@ public class CreateFFmpegProfileHandler :
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
// store what the pipeline will actually use, never a pool size FFmpegState would
// floor away at render time (ersatztv#529)
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
: null,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
@@ -105,7 +110,8 @@ public class CreateFFmpegProfileHandler :
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo
DeinterlaceVideo = request.DeinterlaceVideo,
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
};
});
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
@@ -35,4 +35,5 @@ public record UpdateFFmpegProfile(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -54,7 +55,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
// size that FFmpegState floors away at render time (ersatztv#529)
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
: null;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode;
@@ -102,6 +107,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
// don't save invalid preset
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
@@ -35,4 +35,5 @@ public record FFmpegProfileViewModel(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo);
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
@@ -37,7 +37,8 @@ internal static class Mapper
profile.AudioSampleRate,
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true);
profile.DeinterlaceVideo == true,
profile.QsvPreferNativeDecoder != false);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
new(
@@ -80,5 +81,6 @@ internal static class Mapper
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.NormalizeColors,
ffmpegProfile.DeinterlaceVideo == true);
ffmpegProfile.DeinterlaceVideo == true,
ffmpegProfile.QsvPreferNativeDecoder != false);
}
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
public record GetAllHealthCheckResultsForApi(bool Refresh = false) : IRequest<List<HealthCheckResponseModel>>;
@@ -18,7 +18,8 @@ public class GetAllHealthCheckResultsForApiHandler
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
List<HealthCheckResult> results =
await _healthCheckService.PerformHealthChecks(request.Refresh, cancellationToken);
return results
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
.Map(ProjectToResponseModel)
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Application.Health;
@@ -15,7 +15,7 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(false, cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
@@ -70,9 +70,23 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(MediaKindMustBeSupportedLocally)
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
/// <summary>
/// Mixed is only ever produced for remote (Jellyfin) libraries, where the media server classifies
/// each item for us. No local folder scanner handles it, so a local Mixed library would fail every
/// scan forever. The API takes a raw LibraryMediaKind, so this must be enforced here rather than
/// left to the SPA's media-kind options.
/// </summary>
private static Validation<BaseError, LocalLibrary> MediaKindMustBeSupportedLocally(
LocalLibrary localLibrary) =>
localLibrary.MediaKind is LibraryMediaKind.Mixed
? BaseError.New(
"Local libraries cannot use the Mixed media kind; it is only valid for Jellyfin libraries.")
: localLibrary;
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -74,7 +74,12 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -10,6 +10,16 @@ public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
// data migration.
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
@@ -36,7 +46,7 @@ public class GetAllMediaSourcesForApiHandler(
l.Id,
l.Name,
l.MediaKind,
l.LastScan,
NormalizeLastScan(l.LastScan),
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
@@ -131,9 +131,19 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
long startupMs = (long)segments.ProcessStartup.TotalMilliseconds;
long fillMs = (long)segments.SegmentFill.TotalMilliseconds;
long setupMs = Math.Max(0, totalMs - startupMs - fillMs);
// #472 sub-splits the startup work (81% of total, all of the variance) into the ErsatzTV-side
// prep before FFmpeg is launched, FFmpeg's own init (input open+probe and decoder/encoder
// init), and the wait for the playlist once FFmpeg is reporting progress. splitKind says how
// much of that was actually observable for this sample. NOTE these buckets span the worker's
// Run entry rather than the startup stopwatch, so they do NOT sum to startupMs — prep overlaps
// the tail of setup. The log says "spans runEntry" so a reader can't miss it.
// See ColdStartStartupSplit for the full set of caveats.
ColdStartStartupSplit split = segments.StartupSplit;
_logger.LogInformation(
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
"startup split {SplitKind} spans runEntry (prep {PrepMs}ms + ffmpegInit {FFmpegInitMs}ms " +
"+ firstGop {FirstGopMs}ms), " +
"segments {SegmentsReached}/{InitialSegmentCount}, " +
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
request.ChannelNumber,
@@ -142,6 +152,10 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
setupMs,
startupMs,
fillMs,
split.Kind,
(long)split.Prep.TotalMilliseconds,
(long)split.FFmpegInit.TotalMilliseconds,
(long)split.FirstGop.TotalMilliseconds,
segments.SegmentsReached,
segments.InitialSegmentCount,
segments.DeadlineExpired,
@@ -61,6 +61,14 @@ public class HlsSessionWorker : IHlsSessionWorker
// segments cannot exist until this process ran) — volatile for cross-thread visibility.
private volatile string _coldStartFFmpegArguments;
// Stopwatch timestamps of the cold-start milestones used to sub-split the "startup" phase (#472).
// Each is written once on the sequential Run loop and read on the handler thread from
// WaitForPlaylistSegments; long fields cannot be volatile, so access goes through Volatile/
// Interlocked. Zero means "never reached", which ColdStartStartupSplit degrades gracefully on.
private long _coldStartRunTicks;
private long _coldStartProcessLaunchedTicks;
private long _coldStartFirstProgressTicks;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
@@ -187,6 +195,10 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
// anchor for the cold-start startup sub-split (#472); this runs before any later milestone,
// so every sub-phase derived from it is non-negative by construction
Volatile.Write(ref _coldStartRunTicks, Stopwatch.GetTimestamp());
try
{
_channelNumber = channelNumber;
@@ -314,6 +326,7 @@ public class HlsSessionWorker : IHlsSessionWorker
var sw = Stopwatch.StartNew();
var processStartup = TimeSpan.Zero;
var startupSplit = ColdStartStartupSplit.Unavailable;
var segmentCount = 0;
try
{
@@ -329,6 +342,13 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("Playlist exists");
processStartup = sw.Elapsed;
// #472: sub-split the phase that #350 measured as 81% of cold-start and all of its variance
startupSplit = ColdStartStartupSplit.FromTimestamps(
Volatile.Read(ref _coldStartRunTicks),
Volatile.Read(ref _coldStartProcessLaunchedTicks),
Volatile.Read(ref _coldStartFirstProgressTicks),
Stopwatch.GetTimestamp());
// start the segment-wait deadline only after the playlist file appears,
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
@@ -362,7 +382,8 @@ public class HlsSessionWorker : IHlsSessionWorker
segmentCount,
initialSegmentCount,
segmentCount < initialSegmentCount,
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments),
startupSplit);
}
finally
{
@@ -576,10 +597,30 @@ public class HlsSessionWorker : IHlsSessionWorker
var progressParser = new FFmpegProgress();
// #472: the first -progress line is the only cold-start milestone FFmpeg gives us
// for free (the pipeline runs -loglevel error -nostats, so stderr stays silent on a
// healthy run). It means the input is open and probed and the decoder/encoder are
// initialized. Record-once, so only the session's first process is measured.
void ParseProgressLine(string line)
{
// the read short-circuits the timestamp call for every line after the first,
// which is every line for the life of the session
if (Volatile.Read(ref _coldStartFirstProgressTicks) == 0)
{
Interlocked.CompareExchange(ref _coldStartFirstProgressTicks, Stopwatch.GetTimestamp(), 0);
}
progressParser.ParseLine(line);
}
// everything before this point is ErsatzTV-side "prep" (playout item resolution,
// pipeline build, graphics engine spawn); FFmpeg's own clock starts here
Interlocked.CompareExchange(ref _coldStartProcessLaunchedTicks, Stopwatch.GetTimestamp(), 0);
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(_workingDirectory)
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithStandardOutputPipe(PipeTarget.ToDelegate(ParseProgressLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
@@ -673,6 +714,20 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException
&& cancellationToken.IsCancellationRequested)
{
// a cancellation anywhere in this method (including inside the mediator sends, which sit
// outside the inner ffmpeg try below) is a shutdown or a client disconnect, not a fault.
// Without this it reaches the catch-all and logs a channel-level ERROR with a stack
// trace on every graceful teardown. The token check is load-bearing: TaskCanceledException
// is also what HttpClient throws on ITS OWN timeout, and a real timeout inside ffprobe, a
// media-server call or subtitle extraction must keep its ERROR-level signal rather than
// being downgraded to a routine teardown. (ersatztv#473 review)
_logger.LogInformation("Terminating HLS session for channel {Channel}", _channelNumber);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using CliWrap;
using Dapper;
using ErsatzTV.Application.Playouts;
@@ -42,6 +42,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly IGraphicsElementSelector _graphicsElementSelector;
private readonly IDecoSelector _decoSelector;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRemoteStreamProber _remoteStreamProber;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
private readonly bool _isDebugNoSync;
@@ -62,9 +63,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IWatermarkSelector watermarkSelector,
IGraphicsElementSelector graphicsElementSelector,
IDecoSelector decoSelector,
IRemoteStreamProber remoteStreamProber,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
_remoteStreamProber = remoteStreamProber;
_ffmpegProcessService = ffmpegProcessService;
_fileSystem = fileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
@@ -549,6 +552,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
case PlayoutItemDoesNotExistOnDisk:
case PlayoutItemNotAvailableFromMediaServer:
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
@@ -850,9 +854,15 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
pmf.Path,
pmf.Key);
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
var plexUrl =
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
if (!await _remoteStreamProber.IsAvailable(plexUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(plexUrl);
}
return new PlayoutItemWithPath(playoutItem, plexUrl);
}
break;
@@ -868,9 +878,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
foreach (string itemId in jellyfinItemId)
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}");
var jellyfinUrl = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
if (!await _remoteStreamProber.IsAvailable(jellyfinUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(jellyfinUrl);
}
return new PlayoutItemWithPath(playoutItem, jellyfinUrl);
}
// attempt to remotely stream emby
@@ -883,9 +898,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
foreach (string itemId in embyItemId)
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}");
var embyUrl = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
if (!await _remoteStreamProber.IsAvailable(embyUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(embyUrl);
}
return new PlayoutItemWithPath(playoutItem, embyUrl);
}
return new PlayoutItemDoesNotExistOnDisk(path);
@@ -45,7 +45,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
public async Task<TroubleshootingInfo> Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken)
{
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken);
// Support bundle wants current state, so force a fresh run rather than serving the poll cache.
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(true, cancellationToken);
string version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -118,22 +119,22 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
{ VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau };
foreach (string display in vaapiDisplays)
foreach (VaapiDriver activeDriver in allDrivers)
foreach (string vaapiDevice in vaapiDevices)
{
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
display,
Optional(GetDriverName(activeDriver)),
vaapiDevice))
{
vaapiCapabilities.AppendLine(
CultureInfo.InvariantCulture,
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
vaapiCapabilities.AppendLine();
vaapiCapabilities.AppendLine(output);
vaapiCapabilities.AppendLine();
}
}
foreach (VaapiDriver activeDriver in allDrivers)
foreach (string vaapiDevice in vaapiDevices)
{
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
display,
Optional(GetDriverName(activeDriver)),
vaapiDevice))
{
vaapiCapabilities.AppendLine(
CultureInfo.InvariantCulture,
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
vaapiCapabilities.AppendLine();
vaapiCapabilities.AppendLine(output);
vaapiCapabilities.AppendLine();
}
}
}
if (_runtimeInfo.IsOSPlatform(OSPlatform.OSX))
+2 -2
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name);
new(watermark.Id, watermark.Name, watermark.ImageSource);
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
new(
@@ -0,0 +1,171 @@
using System.Diagnostics;
using ErsatzTV.Core.FFmpeg;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class ColdStartStartupSplitTests
{
// milestones are Stopwatch.GetTimestamp() values; build them from a base + millisecond offsets
private const long Base = 1_000_000_000;
private static long At(double milliseconds) =>
Base + (long)(milliseconds / 1000.0 * Stopwatch.Frequency);
[Test]
public void Should_Split_Three_Ways_When_All_Milestones_Present()
{
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
At(1200),
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
}
[Test]
public void Sub_Phases_Should_Sum_To_Run_Entry_Through_Playlist()
{
// deliberately NOT "should sum to startup": the buckets span the worker's Run entry, which
// begins before the request thread's startup stopwatch, so prep overlaps the tail of setup
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
At(1200),
At(1600));
(split.Prep + split.FFmpegInit + split.FirstGop).TotalMilliseconds.ShouldBe(1600, 1);
}
[Test]
public void Should_Be_Unavailable_When_The_Playlist_Predates_The_Process_Launch()
{
// a stale live.m3u8 survives when the handler's pre-session folder wipe fails (EmptyFolder
// swallows the failure into a warning). Every bucket would be meaningless, so report nothing
// rather than a plausible-looking sample with a prep that exceeds the whole measured phase
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(1600),
0,
At(150));
split.ShouldBe(ColdStartStartupSplit.Unavailable);
}
[Test]
public void Stale_Playlist_Guard_Should_Take_Precedence_Over_The_Progress_Branches()
{
// without the guard, this input would be classified TwoWayLateProgress; the guard must be
// evaluated first. (It can never preempt a ThreeWay: that requires processLaunched <=
// playlistExists, which is exactly the negation of the guard condition.)
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(1600),
At(1700),
At(150));
split.ShouldBe(ColdStartStartupSplit.Unavailable);
}
[Test]
public void Should_Fall_Back_To_Two_Way_Split_When_Progress_Predates_The_Process_Launch()
{
// a progress timestamp older than the launch cannot belong to this process
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(100),
At(150),
At(120),
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
split.FirstGop.ShouldBe(TimeSpan.Zero);
}
[Test]
public void Should_Stay_Three_Way_When_Progress_Coincides_With_A_Boundary()
{
ColdStartStartupSplit atLaunch = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(150), At(1600));
atLaunch.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
atLaunch.FFmpegInit.ShouldBe(TimeSpan.Zero);
atLaunch.FirstGop.TotalMilliseconds.ShouldBe(1450, 1);
ColdStartStartupSplit atPlaylist = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(1600), At(1600));
atPlaylist.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
atPlaylist.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
atPlaylist.FirstGop.ShouldBe(TimeSpan.Zero);
}
[Test]
public void Should_Fall_Back_To_Two_Way_Split_When_FFmpeg_Never_Reported_Progress()
{
// no -progress output before the playlist appeared: ffmpegInit must absorb the remainder
// rather than the split inventing a firstGop boundary that was never observed
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
0,
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
split.FirstGop.ShouldBe(TimeSpan.Zero);
}
[Test]
public void Should_Report_Late_Progress_Distinctly_When_Progress_Arrived_After_The_Playlist()
{
// the playlist is observed on the request thread while progress is recorded on the worker
// thread; a progress milestone outside the phase must not produce a negative bucket
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
At(1800),
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWayLateProgress);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
split.FirstGop.ShouldBe(TimeSpan.Zero);
}
[TestCase(0L, 150L, 1200L, 1600L, TestName = "Run never started")]
[TestCase(100L, 0L, 0L, 1600L, TestName = "Process never launched")]
[TestCase(100L, 150L, 1200L, 0L, TestName = "Playlist never appeared")]
public void Should_Be_Unavailable_When_A_Required_Milestone_Is_Missing(
long runStarted,
long processLaunched,
long firstProgress,
long playlistExists)
{
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
runStarted == 0 ? 0 : At(runStarted),
processLaunched == 0 ? 0 : At(processLaunched),
firstProgress == 0 ? 0 : At(firstProgress),
playlistExists == 0 ? 0 : At(playlistExists));
split.ShouldBe(ColdStartStartupSplit.Unavailable);
}
[Test]
public void Should_Clamp_Rather_Than_Report_A_Negative_Prep()
{
// defensive: launch cannot precede Run entry, but telemetry must never show a negative
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(500),
At(150),
At(1200),
At(1600));
split.Prep.ShouldBe(TimeSpan.Zero);
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
}
}
@@ -0,0 +1,77 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
/// <summary>
/// Pins which watermarks ffmpeg may carry natively and which must go to the graphics engine.
/// The remote-URL rule is the second half of the #502 fix: resolving the URL is useless if the
/// resolved path is then handed to ffmpeg as a bare <c>-i</c> argument.
/// </summary>
[TestFixture]
public class FFmpegNativeWatermarkRoutingTests
{
private const string LocalPath = "/cache/logos/ab/abc123.png";
private static WatermarkOptions Options(
string imagePath,
ChannelWatermarkMode mode = ChannelWatermarkMode.Permanent) =>
new(new ChannelWatermark { Id = 1, Name = "wm", Mode = mode }, imagePath, Option<int>.None);
[Test]
public void Local_Path_Single_Permanent_Watermark_Uses_FFmpeg()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath)])
.ShouldBeTrue();
}
[TestCase("https://cdn.example.com/logos/channel.png")]
[TestCase("http://cdn.example.com/logos/channel.png")]
public void Remote_Url_Watermark_Goes_To_Graphics_Engine(string url)
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(url)])
.ShouldBeFalse();
}
/// <summary>
/// The generated-initials fallback is a localhost URL. Only the deco path still emits it, and it is
/// routed by its resolved path like any other URL — see the #502 entry in docs/decisions.md and #510.
/// </summary>
[Test]
public void Generated_Localhost_Logo_Url_Goes_To_Graphics_Engine()
{
FFmpegLibraryProcessService
.CanUseFFmpegNativeWatermark(0, [Options("http://localhost:8409/iptv/logos/gen?text=Test")])
.ShouldBeFalse();
}
[Test]
public void Graphics_Elements_Present_Goes_To_Graphics_Engine()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(1, [Options(LocalPath)])
.ShouldBeFalse();
}
[Test]
public void Multiple_Watermarks_Go_To_Graphics_Engine()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath), Options(LocalPath)])
.ShouldBeFalse();
}
[Test]
public void No_Watermarks_Does_Not_Use_FFmpeg()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, []).ShouldBeFalse();
}
[TestCase(ChannelWatermarkMode.Intermittent)]
[TestCase(ChannelWatermarkMode.None)]
public void Non_Permanent_Watermark_Goes_To_Graphics_Engine(ChannelWatermarkMode mode)
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath, mode)])
.ShouldBeFalse();
}
}
@@ -0,0 +1,207 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
/// <summary>
/// Covers <see cref="ChannelWatermarkImageSource.ChannelLogo" /> resolution at all three watermark
/// precedence levels (playout item, channel, global). The shared fixture in
/// <see cref="WatermarkSelectorTests" /> deliberately makes every watermark file exist, so it cannot
/// express the "logo is an external URL" or "logo file is gone" cases this fixture exists for (#502).
/// </summary>
[TestFixture]
public class WatermarkSelectorChannelLogoTests
{
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
private const string LocalLogoPath = "abc123.png";
private const string LocalLogoCachePath = "/cache/logos/ab/abc123.png";
private WatermarkSelector _selector;
[SetUp]
public void SetUp()
{
var mockFileSystem = new MockFileSystem();
mockFileSystem.Initialize().WithFile(LocalLogoCachePath);
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
.Returns(_ => LocalLogoCachePath);
_selector = new WatermarkSelector(
mockFileSystem,
fakeImageCache,
Substitute.For<IDecoSelector>(),
NullLogger<WatermarkSelector>.Instance);
}
private static ChannelWatermark ChannelLogoWatermark(int id, string name) =>
new()
{
Id = id,
Name = name,
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
Mode = ChannelWatermarkMode.Permanent
};
private static Channel ChannelWithLogo(string logoPath, ChannelWatermark channelWatermark = null)
{
var channel = new Channel(Guid.Empty)
{
Id = 1,
Number = "1",
Name = "Test",
StreamingMode = StreamingMode.TransportStream,
Artwork = [],
Watermark = channelWatermark,
WatermarkId = channelWatermark?.Id
};
if (logoPath is not null)
{
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
}
return channel;
}
// ---- external URL logo: render path must degrade to no bug, never fetch (#525) --------------
//
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path can only be a
// row that failed migration. The render/watermark path must NOT fetch at compositing time: it degrades
// to None (no on-screen bug) with a warning, rather than handing the URL downstream as a renderable
// ImagePath (the #502 behavior these tests previously pinned).
[Test]
public void PlayoutItemWatermark_Should_Ignore_External_Url_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(1, "PlayoutItem");
Channel channel = ChannelWithLogo(ExternalLogoUrl);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
watermark,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(ExternalLogoUrl, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
// never hand the URL downstream as a renderable path
result.IfSome(o => o.ImagePath.ShouldNotBe(ExternalLogoUrl));
}
[Test]
public void GlobalWatermark_Should_Ignore_External_Url_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(3, "Global");
Channel channel = ChannelWithLogo(ExternalLogoUrl);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
watermark);
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Scheme comparison goes through <see cref="Uri" />, which lower-cases it. Pinned because the fix
/// turns on <c>Artwork.IsExternalUrl</c>, and a case-sensitive check would silently fall back to the
/// existence-gated branch and re-introduce the defect for an oddly-cased URL.
/// </summary>
[Test]
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo_Regardless_Of_Scheme_Case()
{
const string UpperCaseUrl = "HTTPS://cdn.example.com/logos/channel.png";
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(UpperCaseUrl, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
// ---- regressions: local-file behavior must not change ---------------------------------------
[Test]
public void ChannelWatermark_Should_Use_Cached_Path_For_Local_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LocalLogoCachePath);
}
[Test]
public void ChannelWatermark_Should_Be_Ignored_When_Local_Channel_Logo_File_Is_Missing()
{
var mockFileSystem = new MockFileSystem(); // nothing on disk
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
.Returns(_ => LocalLogoCachePath);
var selector = new WatermarkSelector(
mockFileSystem,
fakeImageCache,
Substitute.For<IDecoSelector>(),
NullLogger<WatermarkSelector>.Instance);
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
Option<WatermarkOptions> result = selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Scope guard for #502: with no logo artwork at all, the resolved path is the generated-initials
/// URL from <see cref="Images.ChannelLogoGenerator.GenerateChannelLogoUrl" />, which hardcodes
/// localhost (issue #1). That fallback stays disabled here — reviving it is deliberately deferred
/// in docs/decisions.md and is not part of this fix.
/// </summary>
[Test]
public void ChannelWatermark_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
{
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(null, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,52 @@
using ErsatzTV.Core.Images;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Images;
[TestFixture]
public class RemoteImageDecodeBudgetTests
{
private static readonly Uri Uri = new("https://example.com/logo.png");
// the product is the real bound: 2500x2500 x600 is affordable on each axis alone but not together
[Test]
public void Should_Reject_Dimensions_And_Frames_Affordable_Alone_But_Not_Together()
{
((long)2500 * 2500).ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteDecodedPixels);
600.ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteFrames);
InvalidOperationException ex = Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(2500, 2500, 600, Uri));
ex.Message.ShouldContain("pixel limit");
}
[Test]
public void Should_Reject_Too_Many_Frames_Even_When_Each_Is_Tiny() =>
Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(8, 8, RemoteImageDecodeBudget.MaxRemoteFrames + 1, Uri))
.Message.ShouldContain("frame limit");
[Test]
public void Should_Reject_A_Single_Oversized_Frame() =>
Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDimensionsAffordable(30000, 30000, Uri))
.Message.ShouldContain("pixel limit");
[Test]
public void Should_Allow_A_Single_Large_Still_Within_Budget() =>
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(7680, 4320, 1, Uri));
[Test]
public void Should_Charge_At_Least_One_Frame_When_Header_Reports_None() =>
Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(30000, 30000, 0, Uri));
[Test]
public void Should_Afford_Fewer_Frames_As_Frames_Get_Larger()
{
RemoteImageDecodeBudget.AffordableFrames(8, 8).ShouldBe(RemoteImageDecodeBudget.MaxRemoteFrames);
RemoteImageDecodeBudget.AffordableFrames(1000, 1000).ShouldBe(50);
RemoteImageDecodeBudget.AffordableFrames(7000, 7000).ShouldBe(1);
}
}
@@ -0,0 +1,93 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class PlaybackOrderSupportTests
{
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
// silently.
[Test]
public void EveryOrder_IsClassified_ForEveryEngine()
{
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
foreach (PlaybackOrder order in AllOrders)
{
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
}
}
}
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
[Test]
public void EveryEngineKind_HasAMatrixEntry()
{
var classified = PlaybackOrderSupport.Engines.ToHashSet();
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
{
classified.ShouldContain(engine,
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
}
}
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
// exactly the enum (no stale entry for a removed value, no missing value).
[Test]
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
{
var all = AllOrders.ToHashSet();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
supported.Intersect(unsupported).ShouldBeEmpty(
$"{engine}: an order is listed as both supported and unsupported");
var union = supported.Concat(unsupported).ToHashSet();
union.ShouldBe(all, ignoreOrder: true,
$"{engine}: supported unsupported does not equal the PlaybackOrder enum");
}
}
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
[Test]
public void Block_Supports_Random()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
}
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
// write-path guards and this matrix agree.
[Test]
public void WeightedShuffle_IsClassicOnly()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
.ShouldBeTrue();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
if (engine == SchedulingEngineKind.Classic)
{
continue;
}
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
}
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -433,6 +434,67 @@ public class PlaylistEnumeratorTests
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
}
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
[Test]
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var logger = new RecordingLogger();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11)]
},
{
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
new PlaylistItem
{
Id = 2,
PlaybackOrder = PlaybackOrder.WeightedShuffle,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21)]
}
};
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None,
logger);
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
var items = new List<int>();
for (var i = 0; i < 4; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
items.ShouldContain(10);
items.ShouldContain(11);
items.ShouldNotContain(20);
items.ShouldNotContain(21);
// and it said so, rather than dropping silently
logger.Entries.ShouldContain(
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
}
private static Movie FakeMovie(int id) => new()
{
Id = id,
@@ -445,4 +507,27 @@ public class PlaylistEnumeratorTests
}
]
};
private sealed class RecordingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -16,6 +16,9 @@ public record ChannelGuideProgrammeResponseModel(
public record ChannelGuideChannelResponseModel(
string Number,
string Name,
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo,
List<ChannelGuideProgrammeResponseModel> Programmes);
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
@@ -16,4 +16,7 @@ public record ChannelResponseModel(
string StreamingMode,
bool IsEnabled,
bool ShowInEpg,
int PlayoutCount);
int PlayoutCount,
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo);
@@ -36,4 +36,5 @@ public record FFmpegFullProfileResponseModel(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo);
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder);
@@ -1,4 +1,8 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without
// matching a user-editable name. Additive under the frozen /api/v1 contract (#286).
public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource);
+1
View File
@@ -24,6 +24,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
+3 -1
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Domain;
@@ -14,6 +14,7 @@ public record FFmpegProfile
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int? QsvExtraHardwareFrames { get; set; }
public bool? QsvPreferNativeDecoder { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public ScalingBehavior ScalingBehavior { get; set; }
@@ -63,6 +64,7 @@ public record FFmpegProfile
NormalizeFramerate = false,
HardwareAcceleration = HardwareAccelerationKind.None,
QsvExtraHardwareFrames = 64,
QsvPreferNativeDecoder = true,
NormalizeAudio = true,
NormalizeVideo = true,
NormalizeColors = true
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Domain;
public enum LibraryMediaKind
{
@@ -8,5 +8,13 @@ public enum LibraryMediaKind
OtherVideos = 4,
Songs = 5,
Images = 6,
RemoteStreams = 7
RemoteStreams = 7,
/// <summary>
/// A library whose contents are heterogeneous - movies, shows and music videos together.
/// Only produced for remote (Jellyfin) libraries whose collection type is "mixed", where the
/// media server classifies each item for us. A local library is never Mixed: the local folder
/// scanners all share one video extension list and would claim each other's files.
/// </summary>
Mixed = 8
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors;
public class PlayoutItemNotAvailableFromMediaServer : BaseError
{
public PlayoutItemNotAvailableFromMediaServer(string url) : base(
$"Playout item is not available from media server\n{url}")
{
}
}
@@ -0,0 +1,146 @@
using System.Diagnostics;
namespace ErsatzTV.Core.FFmpeg;
/// <summary>
/// How finely a cold-start's startup work could be broken down (#472).
/// </summary>
public enum ColdStartStartupSplitKind
{
/// <summary>
/// No split available: the FFmpeg process was never launched, the playlist never appeared, or the
/// playlist was observed before FFmpeg was launched (a stale playlist left behind because the
/// pre-session transcode-folder wipe failed — it logs a warning and continues).
/// </summary>
Unavailable = 0,
/// <summary>
/// Two-way split: <c>prep</c> + <c>ffmpegInit</c>, because FFmpeg emitted no progress output at all
/// before the playlist appeared. <c>ffmpegInit</c> therefore runs to the playlist.
/// </summary>
TwoWay = 1,
/// <summary>
/// Two-way split, distinguished because FFmpeg <em>did</em> report progress but only after the
/// playlist was observed. Same buckets as <see cref="TwoWay"/>; kept separate because it means the
/// playlist appeared before the first progress report rather than FFmpeg being silent, which is a
/// different story about the pipeline (and is also what the 100ms playlist poll can manufacture).
/// </summary>
TwoWayLateProgress = 2,
/// <summary>Three-way split: <c>prep</c> + <c>ffmpegInit</c> + <c>firstGop</c>.</summary>
ThreeWay = 3
}
/// <summary>
/// Sub-split of the HLS cold-start startup work (#472), which #350's measurement showed to be 81% of
/// tune-in latency and to carry 100% of its variance while remaining a single opaque bucket.
/// <para>
/// <see cref="Prep"/> is ErsatzTV-side work before FFmpeg exists: playout-item resolution, pipeline
/// build, graphics-engine spawn. <see cref="FFmpegInit"/> is FFmpeg from launch until it first reports
/// progress — input open + probe (the NFS hypothesis) plus decoder/encoder init (the VAAPI-contention
/// hypothesis). <see cref="FirstGop"/> is from that first progress report until <c>live.m3u8</c> exists.
/// </para>
/// <para>
/// <b>These buckets span the session worker's <c>Run</c> entry to the playlist appearing, which is NOT
/// exactly the logged <c>startup</c> phase</b>: the worker is launched fire-and-forget slightly before
/// the request thread starts the <c>startup</c> stopwatch, so <see cref="Prep"/> overlaps the tail of
/// the logged <c>setup</c> bucket (in practice one config read). Do not expect
/// <c>prep + ffmpegInit + firstGop</c> to equal <c>startup</c> — it is a superset by that overlap.
/// </para>
/// <para>
/// Because the pipeline runs <c>-loglevel error -nostats -hide_banner</c>, a healthy FFmpeg writes
/// nothing to stderr, so input-open and encoder-init cannot be separated from each other without
/// changing the FFmpeg command — which this instrumentation deliberately does not do. The
/// <c>-progress</c> stream on stdout is therefore the only zero-cost milestone available, and
/// <see cref="FFmpegInit"/> necessarily lumps those two candidates together. #472 accepts this: a
/// large <see cref="Prep"/> vs a large <see cref="FFmpegInit"/> is itself the first discrimination,
/// and it is honest about what it cannot yet see.
/// </para>
/// <para>
/// Three further caveats when reading these numbers. The playlist is detected by a 100ms poll, so its
/// timestamp is up to 100ms late and that error lands entirely in <see cref="FirstGop"/> — the
/// smallest bucket — and can also flip a sample between <see cref="ColdStartStartupSplitKind.ThreeWay"/>
/// and <see cref="ColdStartStartupSplitKind.TwoWayLateProgress"/>. And if the session's first FFmpeg
/// process fails and a second one produces the playlist, <see cref="FFmpegInit"/> spans the first
/// process's whole lifetime plus the retry while still being labelled as one process's init. And the
/// stale-playlist guard below is best-effort rather than a proof: if the folder wipe failed, whether
/// the stale playlist is observed before or after the launch milestone is a scheduling race, so an
/// unlucky sample could still slip through as an implausibly fast one (most often
/// <see cref="ColdStartStartupSplitKind.TwoWay"/>, since FFmpeg has usually not reported progress
/// that early).
/// </para>
/// </summary>
public readonly record struct ColdStartStartupSplit(
TimeSpan Prep,
TimeSpan FFmpegInit,
TimeSpan FirstGop,
ColdStartStartupSplitKind Kind)
{
public static readonly ColdStartStartupSplit Unavailable =
new(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero, ColdStartStartupSplitKind.Unavailable);
/// <summary>
/// Builds the split from four <see cref="Stopwatch.GetTimestamp"/> milestones; <c>0</c> means the
/// milestone never happened. Milestones are recorded on different threads (the session worker
/// records the launch and progress ones; the request thread observes the playlist), so ordering is
/// validated rather than assumed: any out-of-order or missing milestone degrades the result to a
/// coarser <see cref="ColdStartStartupSplitKind"/> instead of producing a negative or invented bucket.
/// </summary>
public static ColdStartStartupSplit FromTimestamps(
long runStarted,
long processLaunched,
long firstProgress,
long playlistExists)
{
if (runStarted <= 0 || processLaunched <= 0 || playlistExists <= 0)
{
return Unavailable;
}
if (processLaunched > playlistExists)
{
// the playlist was observed before FFmpeg was even launched, so it is a stale file: the
// handler wipes the transcode folder before starting the session, but that wipe swallows
// its failures into a warning (LocalFileSystem.EmptyFolder) and continues.
// Every bucket would be meaningless; report nothing rather than a plausible-looking sample
return Unavailable;
}
// the worker's Run entry strictly precedes every later milestone; clamp anyway so a clock
// oddity can never surface as a negative duration in telemetry
TimeSpan prep = Elapsed(runStarted, processLaunched);
if (firstProgress <= 0 || firstProgress < processLaunched)
{
// FFmpeg reported no usable progress before the playlist appeared: fall back to the two-way
// split #472 explicitly accepts, rather than inventing a boundary that was never observed
return new ColdStartStartupSplit(
prep,
Elapsed(processLaunched, playlistExists),
TimeSpan.Zero,
ColdStartStartupSplitKind.TwoWay);
}
if (firstProgress > playlistExists)
{
return new ColdStartStartupSplit(
prep,
Elapsed(processLaunched, playlistExists),
TimeSpan.Zero,
ColdStartStartupSplitKind.TwoWayLateProgress);
}
return new ColdStartStartupSplit(
prep,
Elapsed(processLaunched, firstProgress),
Elapsed(firstProgress, playlistExists),
ColdStartStartupSplitKind.ThreeWay);
}
private static TimeSpan Elapsed(long from, long to)
{
TimeSpan elapsed = Stopwatch.GetElapsedTime(from, to);
return elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed;
}
}
@@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
@@ -68,6 +68,29 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger = logger;
}
/// <summary>
/// Whether ffmpeg can carry the watermark itself (a single permanent watermark and no other graphics),
/// rather than handing it to the graphics engine.
/// </summary>
/// <remarks>
/// A watermark whose resolved path is a remote URL is always refused here (#502). ffmpeg would receive it
/// as a bare <c>-i</c> argument — and <c>ffprobe</c> the same string for animation detection — putting an
/// unbounded network fetch inside stream startup, with no timeout, redirect or auth handling of ours, and
/// with the image's real dimensions never probed. The graphics engine fetches remote images deliberately
/// (<c>ImageElementBase.LoadImage</c>) and decodes them for their true size.
/// <para>
/// This is decided by the resolved <see cref="WatermarkOptions.ImagePath" /> alone, so it applies
/// uniformly however the watermark was selected — channel, global, playout item <em>or</em> deco. Only
/// `ChannelLogo` watermarks can produce a URL: `Custom` resolves through the image cache and
/// `Resource` through the resources folder, so neither is ever rerouted.
/// </para>
/// </remarks>
internal static bool CanUseFFmpegNativeWatermark(int graphicsElementCount, List<WatermarkOptions> watermarks) =>
graphicsElementCount == 0
&& watermarks.Count == 1
&& watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent
&& !Artwork.IsExternalUrl(wm.ImagePath));
public async Task<PlayoutItemResult> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
@@ -174,7 +197,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
foreach (Subtitle subtitle in maybeSubtitle)
{
if (subtitle.SubtitleKind == SubtitleKind.Sidecar || subtitle is
{ SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true })
{ SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true })
{
// proxy to avoid dealing with escaping
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}";
@@ -287,20 +310,20 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
outputFormat = OutputFormatKind.Hls;
break;
case StreamingMode.HttpLiveStreamingDirect:
{
// use mpeg-ts by default
outputFormat = OutputFormatKind.MpegTs;
// override with setting if applicable
Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository
.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
foreach (OutputFormatKind of in maybeOutputFormat)
{
outputFormat = of;
}
// use mpeg-ts by default
outputFormat = OutputFormatKind.MpegTs;
break;
}
// override with setting if applicable
Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository
.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
foreach (OutputFormatKind of in maybeOutputFormat)
{
outputFormat = of;
}
break;
}
}
Option<string> subtitleLanguage = Option<string>.None;
@@ -379,8 +402,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
List<GraphicsElementContext> graphicsElementContexts = [];
// use ffmpeg for single permanent watermark, graphics engine for all others
if (graphicsElements.Count == 0 && watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent))
if (CanUseFFmpegNativeWatermark(graphicsElements.Count, watermarks))
{
foreach (var wm in watermarks)
{
@@ -445,7 +467,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string> hlsInitTemplate = outputFormat switch
{
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
_ => Option<string>.None
_ => Option<string>.None
};
Option<string> hlsSegmentOptions = Option<string>.None;
@@ -587,7 +609,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -6,6 +6,7 @@ namespace ErsatzTV.Core.FFmpeg;
/// FFmpeg process spawn + probe + libass/encoder init + first GOP, since the wait begins right
/// after the fire-and-forget worker is launched); <see cref="SegmentFill"/> is Phase B (playlist
/// exists -&gt; the requested number of segments are present, or the 8s deadline).
/// <see cref="StartupSplit"/> breaks Phase A down further (#472).
/// </summary>
public readonly record struct PlaylistSegmentsResult(
TimeSpan ProcessStartup,
@@ -13,4 +14,5 @@ public readonly record struct PlaylistSegmentsResult(
int SegmentsReached,
int InitialSegmentCount,
bool DeadlineExpired,
ColdStartFeatures Features);
ColdStartFeatures Features,
ColdStartStartupSplit StartupSplit);
+57 -57
View File
@@ -216,25 +216,7 @@ public class WatermarkSelector(
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from playout item (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
return ChannelLogoWatermarkOptions(channel, watermark);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@@ -265,25 +247,7 @@ public class WatermarkSelector(
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from channel (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(channel.Watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
return ChannelLogoWatermarkOptions(channel, channel.Watermark);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@@ -314,25 +278,7 @@ public class WatermarkSelector(
case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from global (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
foreach (var logoArtwork in maybeLogoArtwork)
{
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
? logoArtwork.Path
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
}
if (fileSystem.File.Exists(channelPath))
{
return new WatermarkOptions(watermark, channelPath, None);
}
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
channelPath);
return None;
return ChannelLogoWatermarkOptions(channel, watermark);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@@ -341,6 +287,52 @@ public class WatermarkSelector(
return Option<WatermarkOptions>.None;
}
/// <summary>
/// Resolves a <see cref="ChannelWatermarkImageSource.ChannelLogo" /> watermark to a renderable path,
/// shared by the playout-item, channel and global precedence levels so all three agree.
/// </summary>
/// <remarks>
/// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path here can only
/// be a row that failed migration. The render path must never fetch at compositing time, so such a row
/// degrades to no watermark (no on-screen bug) with a warning rather than being handed downstream as a
/// renderable URL (the #502 behavior). Other consumers (M3U, XMLTV, SPA JSON) still emit the raw URL for
/// a not-yet-migrated row; only this render/watermark path changed.
/// </remarks>
private Option<WatermarkOptions> ChannelLogoWatermarkOptions(Channel channel, ChannelWatermark watermark)
{
foreach (var logoArtwork in Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)))
{
if (Artwork.IsExternalUrl(logoArtwork.Path))
{
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL here
// means a row that failed migration. Do not fetch at render time; degrade to no bug.
logger.LogWarning(
"Channel logo for channel {Channel} is still an un-downloaded URL {Url}; re-save the "
+ "channel to download it. Rendering without an on-screen bug.",
channel.Number,
logoArtwork.Path);
return None;
}
string cachedPath = imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
if (fileSystem.File.Exists(cachedPath))
{
return new WatermarkOptions(watermark, cachedPath, None);
}
logger.LogWarning("Channel logo no longer exists at {Path} and will be ignored", cachedPath);
return None;
}
// with no logo artwork the only candidate is the generated-initials image, whose URL hardcodes
// localhost (ChannelLogoGenerator.GenerateChannelLogoUrl, issue #1). It has never rendered here and
// reviving it is deliberately deferred in docs/decisions.md, so it stays ignored.
logger.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored",
ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
return None;
}
private List<WatermarkOptions> OptionsForWatermarks(Channel channel, IEnumerable<ChannelWatermark> watermarks)
{
var result = new List<WatermarkOptions>();
@@ -382,6 +374,14 @@ public class WatermarkSelector(
customPath,
None);
case ChannelWatermarkImageSource.ChannelLogo:
// deliberately NOT ChannelLogoWatermarkOptions: the deco path has always passed its resolved
// path through unchecked, so #502's File.Exists defect never reached it and its *resolution*
// is unchanged here. Aligning its missing-file / no-artwork policy with the three precedence
// levels above is a behavior change beyond this fix — tracked in #510.
// Note this only scopes resolution: the ffmpeg-native-vs-graphics-engine routing in
// FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark keys off the resolved path alone, so a
// deco watermark resolving to a URL (an external logo, or the generated-initials URL below) is
// rerouted to the graphics engine like any other. That is intended: it is the URL-aware path.
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork =
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
+2 -2
View File
@@ -1,7 +1,7 @@
namespace ErsatzTV.Core.Health;
namespace ErsatzTV.Core.Health;
public interface IHealthCheckService
{
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
Task<List<HealthCheckResult>> PerformHealthChecks(bool forceRefresh, CancellationToken cancellationToken);
HealthCheckSummary GetHealthCheckSummary();
}
@@ -0,0 +1,54 @@
namespace ErsatzTV.Core.Images;
/// <summary>
/// The decode-budget policy for a remote image, as pure arithmetic so it can be enforced both at
/// render time (graphics engine) and at save time (logo download) without materializing
/// multi-gigabyte images. Extracted from ImageElementBase for reuse. (ersatztv#525, from #511.)
/// </summary>
public static class RemoteImageDecodeBudget
{
/// <summary>
/// Ceiling on TOTAL decoded pixels — width x height x frames, as one product. Checking
/// dimensions and frame count independently does not bound the decode: a 60 KiB 2500x2500 x600
/// GIF passes both a 50 MP dimension check and a 600 frame check and costs ~14 GiB.
/// </summary>
public const long MaxRemoteDecodedPixels = 50_000_000;
/// <summary>Frame ceiling, a cheap legible guard against absurd counts of tiny frames.</summary>
public const int MaxRemoteFrames = 600;
public static void EnsureDimensionsAffordable(int width, int height, Uri uri)
{
long pixels = (long)width * height;
if (pixels > MaxRemoteDecodedPixels)
{
throw new InvalidOperationException(
$"Remote image {uri} is {width}x{height} ({pixels} pixels), over the "
+ $"{MaxRemoteDecodedPixels} pixel limit");
}
}
public static int AffordableFrames(int width, int height)
{
long perFrame = Math.Max((long)width * height, 1);
return (int)Math.Clamp(MaxRemoteDecodedPixels / perFrame, 1, MaxRemoteFrames);
}
public static void EnsureDecodeAffordable(int width, int height, int frameCount, Uri uri)
{
int frames = Math.Max(frameCount, 1);
if (frames > MaxRemoteFrames)
{
throw new InvalidOperationException(
$"Remote image {uri} has {frames} frames, over the {MaxRemoteFrames} frame limit");
}
long totalPixels = (long)width * height * frames;
if (totalPixels > MaxRemoteDecodedPixels)
{
throw new InvalidOperationException(
$"Remote image {uri} decodes to {width}x{height} x{frames} frames "
+ $"({totalPixels} pixels), over the {MaxRemoteDecodedPixels} pixel limit");
}
}
}
@@ -0,0 +1,13 @@
namespace ErsatzTV.Core.Interfaces.Images;
/// <summary>
/// Validates that a stream is a decodable image within the decode budget, throwing if not.
/// Used by the logo save path and the artwork upload path (neither needs the decoded pixels,
/// only "is this safe to cache"). The graphics engine uses the static
/// RemoteImageValidator.DecodeAndValidate instead, which returns the Image it composites.
/// (ersatztv#525)
/// </summary>
public interface IRemoteImageValidator
{
Task Validate(Stream stream, Uri uri, CancellationToken cancellationToken);
}
@@ -0,0 +1,14 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images;
/// <summary>
/// Fetches an external logo URL, validates it against the decode budget, and stores it in the
/// image cache — turning a URL into a cache name so it is thereafter identical to an uploaded
/// logo. Errors are returned, not thrown, so a save handler can surface a 400. (ersatztv#525)
/// </summary>
public interface IRemoteLogoCacher
{
Task<Either<BaseError, string>> CacheFromUrl(Uri uri, CancellationToken cancellationToken);
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
@@ -23,6 +23,17 @@ public interface ILibraryRepository
Task SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task CleanEtagsForLibraryPath(LibraryPath libraryPath);
Task<Option<int>> GetParentFolderId(LibraryPath libraryPath, string folder, CancellationToken cancellationToken);
/// <summary>
/// Returns the <see cref="LibraryFolder" /> at <paramref name="folder" /> under
/// <paramref name="libraryPath" />, creating it if it does not yet exist.
/// </summary>
/// <remarks>
/// The existing folder is looked up from the database by <c>(LibraryPathId, Path)</c>. Callers do
/// <b>not</b> need to eager-load <see cref="LibraryPath.LibraryFolders" /> — the remote (Jellyfin)
/// sync path never does, and relying on that navigation collection here previously NRE'd every
/// Jellyfin music-video scan (ersatztv#488).
/// </remarks>
Task<LibraryFolder> GetOrAddFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder);
Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId);
Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
@@ -47,6 +47,16 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
TLibrary library,
List<string> episodeItemIds,
CancellationToken cancellationToken);
// Cascade helpers (#476): when a parent is swept to FileNotFound because it is gone from the media
// server, the per-parent loop never visits it, so its descendants are never reconciled. These flag
// the descendants by parent MediaItem.Id (Season.ShowId / Episode.SeasonId are on the base tables).
Task<List<int>> FlagFileNotFoundSeasonsForShows(
List<int> showIds,
CancellationToken cancellationToken);
Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
List<int> seasonIds,
CancellationToken cancellationToken);
Task<Option<int>> FlagUnavailable(TLibrary library, TEpisode episode, CancellationToken cancellationToken);
Task<Option<int>> FlagRemoteOnly(TLibrary library, TEpisode episode, CancellationToken cancellationToken);
}
@@ -0,0 +1,24 @@
namespace ErsatzTV.Core.Interfaces.Streaming;
/// <summary>
/// Fetches a remote (http/https) image for the graphics engine, under a bounded timeout and a
/// bounded response size.
/// </summary>
public interface IRemoteImageFetcher
{
/// <summary>
/// Fetches <paramref name="uri" /> fully into memory.
/// </summary>
/// <returns>
/// A seekable, fully-buffered stream positioned at zero. The caller owns and must dispose it.
/// </returns>
/// <remarks>
/// Throws rather than returning a failure value: every call site already wraps element
/// initialization in a catch that disables the element, so a throw degrades to "no watermark"
/// rather than a killed stream. An implementation's own deadline should surface as
/// <see cref="TimeoutException" /> and caller cancellation as
/// <see cref="OperationCanceledException" /> — though note the current call sites catch both
/// alike, so today the distinction only sharpens the log message.
/// </remarks>
Task<Stream> Fetch(Uri uri, CancellationToken cancellationToken);
}
@@ -0,0 +1,28 @@
namespace ErsatzTV.Core.Interfaces.Streaming;
/// <summary>
/// Checks whether a media-server remote-stream URL still resolves to playable media.
/// </summary>
public interface IRemoteStreamProber
{
/// <summary>
/// Probes <paramref name="url" />, following redirects as ffmpeg would.
/// </summary>
/// <returns>
/// <c>false</c> only when the media server itself reported the media gone — i.e. a 404 that
/// arrived <em>after</em> ErsatzTV's own <c>/media/{provider}/...</c> endpoint redirected.
/// Every other outcome returns <c>true</c> (fail-open), including an un-redirected 404: that
/// one came from ErsatzTV's own endpoint, which also 404s when the media source is
/// unconfigured or momentarily missing, and honouring it would blank every item on that
/// source. Timeouts, transport failures and all other status codes likewise return
/// <c>true</c>, so a probe that cannot answer never prevents a tune that would have worked.
/// </returns>
/// <exception cref="OperationCanceledException">
/// May propagate when <paramref name="cancellationToken" /> is cancelled while the probe is
/// in flight. Caller cancellation is a genuine signal (shutdown / client disconnect), not a
/// probe failure, so it is not absorbed by the fail-open behaviour above. Cancelling after
/// the probe has already completed returns normally. The prober's own internal timeout does
/// <em>not</em> throw — it fails open.
/// </exception>
Task<bool> IsAvailable(string url, CancellationToken cancellationToken);
}
@@ -51,15 +51,6 @@ public class BlockPlayoutBuilder(
referenceData.Channel.Number,
referenceData.Channel.Name);
List<PlaybackOrder> allowedPlaybackOrders =
[
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
];
int daysToBuild = await GetDaysToBuild(cancellationToken);
// get blocks to schedule
@@ -163,8 +154,14 @@ public class BlockPlayoutBuilder(
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
{
// TODO: support other playback orders
if (!allowedPlaybackOrders.Contains(blockItem.PlaybackOrder))
if (!PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, blockItem.PlaybackOrder))
{
// Skipping the item silently means it never airs and nothing says why (#403).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by block scheduling; " +
"block item {BlockItemId} will be skipped",
blockItem.PlaybackOrder,
blockItem.Id);
continue;
}
@@ -394,14 +391,31 @@ public class BlockPlayoutBuilder(
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey),
_ => new RandomizedMediaCollectionEnumerator(
// Random is in Block's allow-list; give it an explicit arm rather than relying on the fallback
// below (it previously worked only by coincidence -- #403).
PlaybackOrder.Random => new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 }),
_ => UnsupportedBlockOrderFallback(blockItem.PlaybackOrder, collectionItems)
};
return enumerator;
}
// Defensive: the allow-list in Build already skips unsupported orders, so this should be unreachable. If a
// supported-but-unhandled order ever lands here, be loud instead of silently rotating Random (#403).
private IMediaCollectionEnumerator UnsupportedBlockOrderFallback(
PlaybackOrder playbackOrder,
List<MediaItem> collectionItems)
{
logger.LogWarning(
"Playback order {PlaybackOrder} reached the block enumerator without a handler; falling back to Random",
playbackOrder);
return new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 });
}
private static PlayoutBuildResult CleanUpHistory(
PlayoutReferenceData referenceData,
DateTimeOffset start,
@@ -257,7 +257,8 @@ public class SchedulingEngine(
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
CancellationToken.None,
Optional((ILogger)logger));
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);
var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None);
@@ -0,0 +1,134 @@
using ErsatzTV.Core.Domain;
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
/// orders each one actually handles. This is the single declared support matrix for #403.
/// </summary>
/// <remarks>
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
///
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
/// here.
/// </remarks>
public static class PlaybackOrderSupport
{
private sealed record EngineSupport(
IReadOnlySet<PlaybackOrder> Supported,
IReadOnlySet<PlaybackOrder> Unsupported);
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
new Dictionary<SchedulingEngineKind, EngineSupport>
{
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
[SchedulingEngineKind.Classic] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
},
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
[SchedulingEngineKind.Playlist] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
[SchedulingEngineKind.Block] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
[SchedulingEngineKind.Yaml] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
[SchedulingEngineKind.Scripted] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
})
};
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order);
/// <summary>
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
/// </summary>
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
}
+16 -1
View File
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling;
@@ -121,7 +122,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
CollectionEnumeratorState state,
bool shufflePlaylistItems,
Option<int> batchSize,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Option<ILogger> logger = default)
{
var result = new PlaylistEnumerator
{
@@ -203,6 +205,19 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
break;
case PlaybackOrder.Random:
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);
break;
default:
// An order the playlist engine doesn't handle (#403). Leaving the enumerator null drops
// this item from the playlist silently -- and null is a legitimate state above
// (SeasonEpisode with Count == 0), so nothing downstream can flag it. Say so here.
foreach (ILogger log in logger)
{
log.LogWarning(
"Playback order {PlaybackOrder} is not supported by playlist scheduling; " +
"this item will be dropped from the playlist",
playlistItem.PlaybackOrder);
}
break;
}
}
+19 -2
View File
@@ -1324,7 +1324,8 @@ public class PlayoutBuilder : IPlayoutBuilder
state,
marathonShuffleGroups,
batchSize: Option<int>.None,
cancellationToken);
cancellationToken,
Optional((ILogger)_logger));
}
}
@@ -1458,7 +1459,23 @@ public class PlayoutBuilder : IPlayoutBuilder
goto default;
default:
// TODO: handle this error case differently?
// Say so instead of silently substituting a different, arbitrary-looking order; keep airing on
// the Random fallback so the channel does not go dark on one misconfigured item (#403). A
// supported order can also land here via `goto default` (Marathon that couldn't build its
// enumerator) -- distinguish the two so the log doesn't claim a supported order is unsupported.
if (PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, playbackOrder))
{
_logger.LogWarning(
"Could not build a {PlaybackOrder} enumerator for classic scheduling; falling back to Random",
playbackOrder);
}
else
{
_logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by classic scheduling; falling back to Random",
playbackOrder);
}
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
}
}
@@ -0,0 +1,23 @@
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that dispatch a <see cref="ErsatzTV.Core.Domain.PlaybackOrder" /> into an
/// enumerator. Used to key the <see cref="PlaybackOrderSupport" /> matrix (#403).
/// </summary>
public enum SchedulingEngineKind
{
/// <summary>Classic schedules — <c>PlayoutBuilder</c>.</summary>
Classic,
/// <summary>Playlist items — <c>PlaylistEnumerator</c>.</summary>
Playlist,
/// <summary>Block scheduling — <c>BlockPlayoutBuilder</c>.</summary>
Block,
/// <summary>YAML (sequential) scheduling — <c>EnumeratorCache</c>.</summary>
Yaml,
/// <summary>Scripted scheduling — <c>SchedulingEngine.EnumeratorForContent</c>.</summary>
Scripted
}
@@ -175,7 +175,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
cancellationToken,
Optional(logger));
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
@@ -35,4 +35,20 @@ public class QsvHardwareAccelerationOptionTests
"-filter_hw_device", "hw"
]);
}
[Test]
public void GlobalOptions_WithHardwareDecode_AndPreferNative_ShouldUseVaapiDecodeToSoftware()
{
var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Hardware, preferNativeDecoder: true);
option.GlobalOptions.ShouldBe(
[
"-hwaccel", "vaapi",
"-init_hw_device", "vaapi=va:/dev/dri/renderD128",
"-init_hw_device", "qsv=hw@va",
"-filter_hw_device", "hw"
]);
// must NOT keep frames on the GPU as VA-API surfaces
option.GlobalOptions.ShouldNotContain("-hwaccel_output_format");
}
}
@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg.Tests.Pipeline;
[TestFixture]
public class QsvPipelineBuilderTests
{
private readonly ILogger _logger = Substitute.For<ILogger>();
[Test]
public void Qsv_PreferNativeDecoder_Should_Decode_Via_Vaapi_To_Software_Then_Qsv_Encode()
{
string command = BuildAndPrint(preferNativeDecoder: true);
// VA-API decode, frames downloaded to software (NO hwaccel_output_format)
command.ShouldContain("-hwaccel vaapi");
command.ShouldNotContain("-hwaccel_output_format");
command.ShouldNotContain("-hwaccel qsv");
// no QSV *decoder* input option (decoder input options sit directly before "-readrate"/"-i";
// "-c:v h264_qsv -" alone would also match the encoder's "-c:v h264_qsv -low_power ..." output option)
command.ShouldNotContain("-c:v h264_qsv -readrate");
// derived-device chain retained for the QSV encoder
command.ShouldContain("-init_hw_device vaapi=va:/dev/dri/renderD128");
command.ShouldContain("-init_hw_device qsv=hw@va");
// software frames re-uploaded before QSV filters/encoder (proves NO bare vpp_qsv on VA-API frames)
command.ShouldContain("hwupload=extra_hw_frames");
// QSV encoder still used
command.ShouldContain("h264_qsv");
}
[Test]
public void Qsv_Default_Should_Decode_And_Encode_With_Qsv()
{
string command = BuildAndPrint(preferNativeDecoder: false);
command.ShouldContain("-hwaccel qsv");
command.ShouldContain("-hwaccel_output_format qsv");
command.ShouldContain("h264_qsv");
command.ShouldNotContain("-hwaccel vaapi");
}
[Test]
public void Qsv_PreferNativeDecoder_Interlaced_Should_Hwupload_Before_Deinterlace_Qsv()
{
string command = BuildInterlacedAndPrint();
// VA-API decode, software frames
command.ShouldContain("-hwaccel vaapi");
command.ShouldNotContain("-hwaccel_output_format");
// software frames re-uploaded BEFORE deinterlace_qsv (never a bare deinterlace_qsv on VA-API frames)
command.ShouldContain("hwupload=extra_hw_frames");
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
// exactly one deinterlace_qsv, and (assertion above) it is preceded by hwupload — so
// there is no second, bare deinterlace_qsv running on VA-API frames
(command.Split("deinterlace_qsv").Length - 1).ShouldBe(1);
command.ShouldContain("h264_qsv");
}
// ersatztv#529: a stored qsvExtraHardwareFrames of 0 produced hwupload=extra_hw_frames=0, which
// leaves the QSV pool no headroom. Measured on the deployed FFmpeg 8.1.2: with 0 the filter graph
// fails with -12 and writes zero segments as soon as the input is not throttled (a work-ahead
// start, or #350's cold-start burst); with 64 the same command writes segments either way.
[TestCase(-1)]
[TestCase(0)]
[TestCase(1)]
[TestCase(63)]
public void Qsv_Should_Never_Upload_With_Less_Than_Minimum_Extra_Hardware_Frames(int configured)
{
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: configured);
// pinned to the literal value measured against the deployed FFmpeg, not to the constant, so
// that lowering the floor in code cannot quietly satisfy this test
command.ShouldContain("hwupload=extra_hw_frames=64");
// every upload site in the whole command, not just the one this pipeline happens to emit
ShouldNeverUploadBelowMinimum(command);
}
[Test]
public void Qsv_Interlaced_Should_Never_Deinterlace_Upload_Below_Minimum_Extra_Hardware_Frames()
{
// the deinterlace upload is a separate formatter fed from QsvPipelineBuilder, so it needs
// its own guard (ersatztv#529)
string command = BuildInterlacedAndPrint(maybeExtraHardwareFrames: 0);
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
ShouldNeverUploadBelowMinimum(command);
}
private static void ShouldNeverUploadBelowMinimum(string command)
{
MatchCollection matches = Regex.Matches(command, @"extra_hw_frames=(-?\d+)");
matches.Count.ShouldBeGreaterThan(0, "expected the pipeline to upload to QSV at all");
foreach (Match match in matches)
{
int.Parse(match.Groups[1].Value)
.ShouldBeGreaterThanOrEqualTo(FFmpegState.MinimumQsvExtraHardwareFrames, command);
}
}
[Test]
public void Qsv_Should_Honor_Extra_Hardware_Frames_Above_The_Minimum()
{
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: 128);
command.ShouldContain("hwupload=extra_hw_frames=128");
}
private string BuildInterlacedAndPrint(Option<int> maybeExtraHardwareFrames = default) =>
BuildAndPrint(
preferNativeDecoder: true,
maybeExtraHardwareFrames,
ScanKind.Interlaced,
deinterlace: true);
private string BuildAndPrint(
bool preferNativeDecoder,
Option<int> maybeExtraHardwareFrames = default,
ScanKind scanKind = ScanKind.Progressive,
bool deinterlace = false)
{
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
BuildQsvH264Pipeline(preferNativeDecoder, scanKind, deinterlace);
ffmpegState = ffmpegState with { MaybeQsvExtraHardwareFrames = maybeExtraHardwareFrames };
var builder = new QsvPipelineBuilder(
new DefaultFFmpegCapabilities(),
new DefaultHardwareCapabilities(),
HardwareAccelerationMode.Qsv,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
return PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
}
private static (VideoInputFile, AudioInputFile, FFmpegState, FrameState) BuildQsvH264Pipeline(
bool preferNativeDecoder,
ScanKind scanKind,
bool deinterlace)
{
var videoInputFile = new VideoInputFile(
"/tmp/whatever.mkv",
new List<VideoStream>
{
new(
0,
VideoFormat.H264,
VideoProfile.Main,
new PixelFormatYuv420P(),
ColorParams.Default,
new FrameSize(1920, 1080),
"1:1",
"16:9",
FrameRate.DefaultFrameRate,
false,
scanKind)
});
var audioInputFile = new AudioInputFile(
"/tmp/whatever.mkv",
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
new AudioState(
AudioFormat.Aac,
2,
320,
640,
48,
false,
AudioFilter.None,
Option<double>.None));
var desiredState = new FrameState(
true,
false,
VideoFormat.H264,
VideoProfile.Main,
VideoPreset.Unset,
false,
new PixelFormatYuv420P(),
new FrameSize(1280, 720),
new FrameSize(1280, 720),
Option<FrameSize>.None,
FFmpegFilterMode.Software,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
deinterlace);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.Qsv,
HardwareAccelerationMode.Qsv,
Option<string>.None,
"/dev/dri/renderD128",
Option<TimeSpan>.None,
Option<TimeSpan>.None,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
TimeSpan.Zero,
Option<int>.None,
Option<int>.None,
false,
false,
"linear",
false,
preferNativeDecoder);
return (videoInputFile, audioInputFile, ffmpegState, desiredState);
}
private static string PrintCommand(
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
Option<GraphicsEngineInput> graphicsEngineInput,
FFmpegPipeline pipeline)
{
IList<string> arguments = CommandGenerator.GenerateArguments(
videoInputFile,
audioInputFile,
watermarkInputFile,
concatInputFile,
graphicsEngineInput,
pipeline.PipelineSteps,
pipeline.IsIntelVaapiOrQsv);
var command = string.Join(" ", arguments);
Console.WriteLine($"Generated command: ffmpeg {string.Join(" ", arguments)}");
return command;
}
public class DefaultFFmpegCapabilities() : FFmpegCapabilities(
string.Empty,
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>());
}
@@ -1,5 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Format;
@@ -531,6 +532,168 @@ public class PipelineBuilderBaseTests
"-nostdin -hide_banner -nostats -loglevel error -i /test/input/file.png -vf scale=-1:200:force_original_aspect_ratio=decrease /test/output/file.jpg");
}
[Test]
public void Realtime_Input_Should_Burst_Initial_Segments_When_Option_Is_Supported()
{
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities());
// burst covers the first two 4s segments, then the 1.05 throttle resumes (ersatztv#350).
// anchor on the input path so this can't be satisfied by some other input carrying the
// burst; audio and video share one file here, so exactly one input is expected
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -i /tmp/whatever.mkv");
Regex.Matches(command, "-readrate_initial_burst 8").Count.ShouldBe(1);
}
[Test]
public void Realtime_Input_Should_Not_Burst_When_Option_Is_Unsupported()
{
string command = BuildRealtimeCommand(new DefaultFFmpegCapabilities());
command.ShouldContain("-readrate 1.05 -i");
command.ShouldNotContain("-readrate_initial_burst");
}
[Test]
public void Realtime_Input_Should_Not_Burst_A_Still_Image()
{
// a still image is paced by the realtime filter, so bursting would only run audio ahead
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities(), stillImage: true);
// the positive anchor keeps this from passing vacuously if the helper ever stops
// producing a realtime audio input at all
command.ShouldContain("-readrate 1.05");
command.ShouldNotContain("-readrate_initial_burst");
}
[Test]
public void Concat_Should_Never_Burst()
{
// concat reads already-written segments from the running segmenter; bursting would
// gallop through them
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", new FrameSize(1920, 1080));
var builder = new SoftwarePipelineBuilder(
new BurstCapableFFmpegCapabilities(),
HardwareAccelerationMode.None,
None,
None,
None,
None,
concatInputFile,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
string command = PrintCommand(None, None, None, concatInputFile, None, result);
command.ShouldContain("-readrate 1.0");
command.ShouldNotContain("-readrate_initial_burst");
}
private string BuildRealtimeCommand(IFFmpegCapabilities capabilities, bool stillImage = false)
{
var videoInputFile = new VideoInputFile(
"/tmp/whatever.mkv",
new List<VideoStream>
{
new(
0,
VideoFormat.H264,
VideoProfile.Main,
new PixelFormatYuv420P(),
ColorParams.Default,
new FrameSize(1920, 1080),
"1:1",
"16:9",
FrameRate.DefaultFrameRate,
stillImage,
ScanKind.Progressive)
});
var desiredState = new FrameState(
true,
false,
VideoFormat.Hevc,
VideoProfile.Main,
VideoPreset.Unset,
false,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
FFmpegFilterMode.HardwareIfPossible,
false,
Option<FrameRate>.None,
2000,
4000,
90_000,
false,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
TimeSpan.FromSeconds(1),
Option<TimeSpan>.None,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
TimeSpan.Zero,
Option<int>.None,
Option<int>.None,
false,
false,
"clip",
false);
// a *separate* audio input matters here: for a still image the video input takes no readrate
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
// song shape — cover art plus an audio file)
var audioInputFile = new AudioInputFile(
stillImage ? "/tmp/whatever.mp3" : "/tmp/whatever.mkv",
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
new AudioState(
AudioFormat.Aac,
2,
320,
640,
48,
false,
AudioFilter.None,
Option<double>.None));
var builder = new SoftwarePipelineBuilder(
capabilities,
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
return PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
}
private static string PrintCommand(
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
@@ -563,4 +726,13 @@ public class PipelineBuilderBaseTests
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>());
public class BurstCapableFFmpegCapabilities() : FFmpegCapabilities(
string.Empty,
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string> { FFmpegKnownOption.ReadrateInitialBurst.Name },
new System.Collections.Generic.HashSet<string>());
}
@@ -10,7 +10,11 @@ public record FFmpegKnownOption
public string Name { get; }
// ffmpeg 6.1+; lets a readrate-throttled input read flat out for an initial window
public static FFmpegKnownOption ReadrateInitialBurst => new("readrate_initial_burst");
public static IList<string> AllOptions =>
[
ReadrateInitialBurst.Name
];
}
@@ -0,0 +1,19 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Decoder;
// VA-API-accelerated decode that downloads frames to system memory (no
// -hwaccel_output_format). Pairs with `-hwaccel vaapi` from
// QsvHardwareAccelerationOption on the "prefer native decoder" QSV path: the
// error-tolerant VA-API decoder feeds software frames into the QSV builder's
// format=nv12,hwupload,vpp_qsv branch, which re-uploads for the QSV encoder.
public class DecoderVaapiToSoftware : DecoderBase
{
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Software;
public override string Name => "implicit_vaapi";
// no -c:v (implicit decoder; `-hwaccel vaapi` drives VA-API) and no
// -hwaccel_output_format (frames download to software)
public override string[] InputOptions(InputFile inputFile) => [];
}
+14 -3
View File
@@ -1,4 +1,4 @@
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.FFmpeg;
@@ -27,9 +27,20 @@ public record FFmpegState(
bool IsSongWithProgress,
bool IsHdrTonemap,
string TonemapAlgorithm,
bool IsTroubleshooting)
bool IsTroubleshooting,
bool QsvPreferNativeDecoder = false)
{
public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64);
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
// fails with -12 (Cannot allocate memory), h264_qsv reports "Could not open encoder before
// EOF", and the output file gets no packets at all. Input throttling was the only thing
// hiding it — a work-ahead start (no -readrate) and #350's cold-start burst both remove that
// throttle, so the channel simply dies. A stored 0 is therefore treated as "no pool
// configured" rather than honored literally (ersatztv#529)
public const int MinimumQsvExtraHardwareFrames = 64;
public int QsvExtraHardwareFrames =>
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
public static FFmpegState Concat(bool saveReport, string channelName) =>
new(
@@ -1,9 +1,12 @@
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapability decodeCapability) : GlobalOption
public class QsvHardwareAccelerationOption(
Option<string> device,
FFmpegCapability decodeCapability,
bool preferNativeDecoder = false) : GlobalOption
{
// TODO: read this from ffmpeg output
private readonly List<string> _supportedFFmpegFormats = new()
@@ -16,15 +19,18 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
{
get
{
var result = new List<string>
{
"-hwaccel", "qsv",
"-hwaccel_output_format", "qsv"
};
var result = new List<string>();
if (decodeCapability is not FFmpegCapability.Hardware)
if (decodeCapability is FFmpegCapability.Hardware)
{
result.Clear();
// native path: decode with the error-tolerant VA-API decoder and let ffmpeg
// download frames to system memory (no -hwaccel_output_format), so the QSV
// filter graph's software->hwupload branch bridges them to the QSV encoder.
// default path: decode (and keep frames) on QSV.
result.AddRange(
preferNativeDecoder
? ["-hwaccel", "vaapi"]
: ["-hwaccel", "qsv", "-hwaccel_output_format", "qsv"]);
}
var deviceConfigured = false;
@@ -1,19 +1,37 @@
using System.Globalization;
using System.Globalization;
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.InputOption;
public class ReadrateInputOption(double readRate) : IInputOption
public class ReadrateInputOption(double readRate, Option<int> initialBurstSeconds) : IInputOption
{
public ReadrateInputOption(double readRate)
: this(readRate, Option<int>.None)
{
}
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) =>
[
"-readrate",
readRate.ToString("0.0####", CultureInfo.InvariantCulture)
];
public string[] InputOptions(InputFile inputFile)
{
var result = new List<string>
{
"-readrate",
readRate.ToString("0.0####", CultureInfo.InvariantCulture)
};
// burst-read this much input before the readrate throttle kicks in, so a cold start doesn't
// have to wait ~realtime for the first segment to be written (ersatztv#350)
foreach (int burst in initialBurstSeconds)
{
result.Add("-readrate_initial_burst");
result.Add(burst.ToString(CultureInfo.InvariantCulture));
}
return result.ToArray();
}
public string[] FilterOptions => [];
public string[] OutputOptions => [];
@@ -17,6 +17,11 @@ namespace ErsatzTV.FFmpeg.Pipeline;
public abstract class PipelineBuilderBase : IPipelineBuilder
{
// enough input to cover the first couple of HLS segments; the segmenter waits for
// ffmpeg.segmenter.initial_segment_count (default 1) of them before serving the playlist.
// an operator who raises that setting above 2 gets less of the benefit (ersatztv#350)
private const int InitialBurstSeconds = OutputFormatHls.SegmentSeconds * 2;
private readonly Option<AudioInputFile> _audioInputFile;
private readonly Option<ConcatInputFile> _concatInputFile;
private readonly IFFmpegCapabilities _ffmpegCapabilities;
@@ -850,8 +855,24 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
double readRate = desiredState.VideoFormat == VideoFormat.Copy ? 1.0 : 1.05;
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate)));
videoInputFile.AddOption(new ReadrateInputOption(readRate));
// without a burst, the readrate throttle applies from the very first read, so the first
// segment cannot be written faster than ~realtime and every start pays a multi-second wait.
// burst enough input to cover the first segments, then settle to readRate. note this is
// per ffmpeg process, i.e. per playout item, not only on the session's cold start
// (ersatztv#350)
//
// a still image is paced by the realtime filter instead, and its video input takes no
// readrate at all, so bursting there would only run the audio input ahead of the video
bool isStillImage = videoInputFile.VideoStreams.Any(s => s.StillImage);
Option<int> initialBurstSeconds =
!isStillImage && _ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateInitialBurst)
? InitialBurstSeconds
: Option<int>.None;
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds)));
videoInputFile.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds));
}
protected static void SetStillImageLoop(
+31 -5
View File
@@ -51,7 +51,8 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
}
protected override bool IsIntelVaapiOrQsv(FFmpegState ffmpegState) =>
ffmpegState.DecoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv ||
ffmpegState.DecoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv
or HardwareAccelerationMode.Vaapi ||
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv;
protected override FFmpegState SetAccelState(
@@ -61,6 +62,21 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
PipelineContext context,
ICollection<IPipelineStep> pipelineSteps)
{
// surface the floor rather than applying it silently: a profile that deliberately asked for
// a smaller pool now gets a larger one, which costs additional surfaces (ersatztv#529)
foreach (int configured in ffmpegState.MaybeQsvExtraHardwareFrames
.Filter(f => f < FFmpegState.MinimumQsvExtraHardwareFrames))
{
// this fires per pipeline build, before we know whether this particular pipeline uploads
// at all — a fully-hardware path may consume neither value — so word it conditionally
_logger.LogWarning(
"QSV extra hardware frames is configured as {Configured}, which leaves the upload pool too "
+ "little headroom and fails transcoding on any unthrottled read; will use {Applied} "
+ "wherever frames are uploaded",
configured,
FFmpegState.MinimumQsvExtraHardwareFrames);
}
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
@@ -102,13 +118,22 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
// give a bogus value so no cuda devices are visible to ffmpeg
pipelineSteps.Add(new CudaVisibleDevicesVariable("999"));
pipelineSteps.Add(new QsvHardwareAccelerationOption(ffmpegState.VaapiDevice, decodeCapability));
// native (VA-API) decode is a Linux-only path: ffmpeg has no vaapi hwaccel on
// Windows, where QSV capabilities are also over-reported, so keep QSV decode there
bool preferNativeDecode = ffmpegState.QsvPreferNativeDecoder != false && !OperatingSystem.IsWindows();
pipelineSteps.Add(new QsvHardwareAccelerationOption(
ffmpegState.VaapiDevice,
decodeCapability,
preferNativeDecode));
// disable hw accel if decoder/encoder isn't supported
return ffmpegState with
{
DecoderHardwareAccelerationMode = decodeCapability == FFmpegCapability.Hardware
? HardwareAccelerationMode.Qsv
? preferNativeDecode
? HardwareAccelerationMode.Vaapi
: HardwareAccelerationMode.Qsv
: HardwareAccelerationMode.None,
EncoderHardwareAccelerationMode = encodeCapability == FFmpegCapability.Hardware
? HardwareAccelerationMode.Qsv
@@ -130,6 +155,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
(HardwareAccelerationMode.Qsv, VideoFormat.Vc1) => new DecoderVc1Qsv(),
(HardwareAccelerationMode.Qsv, VideoFormat.Vp9) => new DecoderVp9Qsv(),
(HardwareAccelerationMode.Qsv, VideoFormat.Av1) => new DecoderAv1Qsv(),
(HardwareAccelerationMode.Vaapi, _) => new DecoderVaapiToSoftware(),
_ => GetSoftwareDecoder(videoStream)
};
@@ -202,7 +228,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
// need to download for any sort of overlay (and always for setpts)
if (currentState.FrameDataLocation == FrameDataLocation.Hardware) //&&
//(context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine))
//(context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine))
{
var hardwareDownload = new HardwareDownloadFilter(currentState);
currentState = hardwareDownload.NextState(currentState);
@@ -629,7 +655,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
DecoderHardwareAccelerationMode: HardwareAccelerationMode.None,
EncoderHardwareAccelerationMode: HardwareAccelerationMode.None
} && context is
{ HasGraphicsEngine: false, HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
{ HasGraphicsEngine: false, HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
// auto_scale filter seems to muck up 10-bit software decode => hardware scale, so use software scale in that case
useSoftwareFilter = useSoftwareFilter ||
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class NullOutNeverScannedLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// irreversible data migration; there is no way to recover the original sentinel values
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_QsvPreferNativeDecoder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "QsvPreferNativeDecoder",
table: "FFmpegProfile",
type: "tinyint(1)",
nullable: true,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "QsvPreferNativeDecoder",
table: "FFmpegProfile");
}
}
}
@@ -906,6 +906,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("QsvExtraHardwareFrames")
.HasColumnType("int");
b.Property<bool?>("QsvPreferNativeDecoder")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<int>("ResolutionId")
.HasColumnType("int");
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class NullOutNeverScannedLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// irreversible data migration; there is no way to recover the original sentinel values
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_QsvPreferNativeDecoder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "QsvPreferNativeDecoder",
table: "FFmpegProfile",
type: "INTEGER",
nullable: true,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "QsvPreferNativeDecoder",
table: "FFmpegProfile");
}
}
}
@@ -873,6 +873,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("QsvExtraHardwareFrames")
.HasColumnType("INTEGER");
b.Property<bool?>("QsvPreferNativeDecoder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
@@ -0,0 +1,151 @@
using System.Buffers.Binary;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Infrastructure.Images;
using NUnit.Framework;
using Shouldly;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Tests.Images;
[TestFixture]
public class RemoteImageValidatorTests
{
private static readonly Uri Uri = new("https://example.com/logo.png");
// decode cases exercise the static method (used by the render path)
[Test]
public async Task Should_Decode_A_Normal_Image()
{
await using MemoryStream stream = await RealPng(64, 32);
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
image.Width.ShouldBe(64);
image.Height.ShouldBe(32);
}
[Test]
public async Task Should_Reject_A_Decompression_Bomb_By_Declared_Dimensions()
{
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
ex.Message.ShouldContain("pixel limit");
}
[Test]
public async Task Should_Reject_An_Apng_Whose_Header_Under_Reports_Its_Frames()
{
await using MemoryStream stream = Apng(64, 64, RemoteImageDecodeBudget.MaxRemoteFrames + 100);
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
ex.Message.ShouldContain("frame limit");
}
[Test]
public async Task Should_Decode_An_Apng_That_A_Default_Identify_Cannot_Read()
{
await using MemoryStream stream = Apng(288, 288, 60);
stream.Position = 0;
await Should.ThrowAsync<Exception>(() => Image.IdentifyAsync(stream));
stream.Position = 0;
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
image.Frames.Count.ShouldBe(60);
}
// the Core interface Validate() is the save/upload contract: throws on invalid, returns on valid,
// never surfaces an ImageSharp type
[Test]
public async Task Validate_Returns_On_A_Good_Image()
{
IRemoteImageValidator validator = new RemoteImageValidator();
await using MemoryStream stream = await RealPng(64, 32);
await Should.NotThrowAsync(() => validator.Validate(stream, Uri, CancellationToken.None));
}
[Test]
public async Task Validate_Throws_On_A_Bomb()
{
IRemoteImageValidator validator = new RemoteImageValidator();
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
await Should.ThrowAsync<InvalidOperationException>(
() => validator.Validate(stream, Uri, CancellationToken.None));
}
/// <summary>A real multi-frame APNG. Small on the wire, many frames — the shape that matters.</summary>
private static MemoryStream Apng(int width, int height, int frames)
{
using var image = new Image<Rgba32>(width, height);
for (var i = 1; i < frames; i++)
{
image.Frames.CreateFrame();
}
var stream = new MemoryStream();
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
stream.Position = 0;
return stream;
}
// PNG chunk CRC-32 (IEEE, reflected). hand-rolled because the repo does not reference
// System.IO.Hashing, and ImageSharp validates the CRC of critical chunks like IHDR.
private static uint Crc32(ReadOnlySpan<byte> data)
{
uint crc = 0xFFFFFFFF;
foreach (byte b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
}
}
return crc ^ 0xFFFFFFFF;
}
/// <summary>A real, decodable PNG.</summary>
private static async Task<MemoryStream> RealPng(int width, int height)
{
using var image = new Image<Rgba32>(width, height);
var stream = new MemoryStream();
await image.SaveAsync(stream, new PngEncoder());
stream.Position = 0;
return stream;
}
/// <summary>
/// A PNG signature plus a single valid IHDR chunk declaring <paramref name="width" /> x
/// <paramref name="height" /> and nothing else — enough for Identify, far too little to
/// decode. This is what a decompression bomb looks like at the point we have to reject it.
/// </summary>
private static MemoryStream PngHeaderDeclaring(int width, int height)
{
var stream = new MemoryStream();
stream.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
var ihdr = new byte[17];
"IHDR"u8.CopyTo(ihdr);
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4), width);
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(8), height);
ihdr[12] = 8; // bit depth
ihdr[13] = 6; // color type: truecolor + alpha
ihdr[14] = 0; // compression
ihdr[15] = 0; // filter
ihdr[16] = 0; // interlace
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, 13);
stream.Write(length);
stream.Write(ihdr);
var crc = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(ihdr));
stream.Write(crc);
stream.Position = 0;
return stream;
}
}
@@ -0,0 +1,79 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Images;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using static LanguageExt.Prelude;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Tests.Images;
[TestFixture]
public class RemoteLogoCacherTests
{
private static readonly Uri Uri = new("https://example.com/logo.png");
[Test]
public async Task Should_Fetch_Validate_And_Cache_Returning_The_Name()
{
MemoryStream png = await RealPng(64, 64);
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Uri, Arg.Any<CancellationToken>()).Returns(png);
var validator = Substitute.For<IRemoteImageValidator>();
validator.Validate(png, Uri, Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var cache = Substitute.For<IImageCache>();
cache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo).Returns(Right<BaseError, string>("abc123"));
var cacher = new RemoteLogoCacher(fetcher, validator, cache);
Either<BaseError, string> result = await cacher.CacheFromUrl(Uri, CancellationToken.None);
result.IsRight.ShouldBeTrue();
result.IfRight(name => name.ShouldBe("abc123"));
}
[Test]
public async Task Should_Return_Left_When_The_Fetch_Throws()
{
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Uri, Arg.Any<CancellationToken>()).Returns<Stream>(_ => throw new TimeoutException("timed out"));
var cacher = new RemoteLogoCacher(fetcher, Substitute.For<IRemoteImageValidator>(), Substitute.For<IImageCache>());
Either<BaseError, string> result = await cacher.CacheFromUrl(Uri, CancellationToken.None);
result.IsLeft.ShouldBeTrue();
result.IfLeft(e => e.Value.ShouldContain("timed out"));
}
[Test]
public async Task Should_Return_Left_When_Validation_Rejects_A_Bomb()
{
MemoryStream png = await RealPng(64, 64);
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Uri, Arg.Any<CancellationToken>()).Returns(png);
var validator = Substitute.For<IRemoteImageValidator>();
validator.Validate(png, Uri, Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("over the 50000000 pixel limit"));
var cacher = new RemoteLogoCacher(fetcher, validator, Substitute.For<IImageCache>());
Either<BaseError, string> result = await cacher.CacheFromUrl(Uri, CancellationToken.None);
result.IsLeft.ShouldBeTrue();
result.IfLeft(e => e.Value.ShouldContain("pixel limit"));
}
private static async Task<MemoryStream> RealPng(int w, int h)
{
using var img = new Image<Rgba32>(w, h);
var ms = new MemoryStream();
await img.SaveAsync(ms, new PngEncoder());
ms.Position = 0;
return ms;
}
}
@@ -53,6 +53,141 @@ public class JellyfinApiClientTests
libraries[0].ShouldSyncItems.ShouldBeFalse();
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
}
[Test]
public async Task Should_Project_Mixed_Libraries()
{
const string response = """
[
{
"Name": "Music Videos",
"CollectionType": "mixed",
"ItemId": "library-9",
"LibraryOptions": {
"PathInfos": []
}
}
]
""";
var client = new JellyfinApiClient(
new MemoryCache(new MemoryCacheOptions()),
Substitute.For<IJellyfinPathReplacementService>(),
Substitute.For<IFallbackMetadataProvider>(),
new SingleResponseHttpClientFactory(response),
Substitute.For<ILogger<JellyfinApiClient>>());
Either<BaseError, List<JellyfinLibrary>> result =
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
result.IsRight.ShouldBeTrue();
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
libraries.Count.ShouldBe(1);
libraries[0].Name.ShouldBe("Music Videos");
libraries[0].ItemId.ShouldBe("library-9");
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
libraries[0].ShouldSyncItems.ShouldBeFalse();
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-9");
}
[Test]
public async Task Should_Project_Libraries_With_No_CollectionType_As_Mixed()
{
const string response = """
[
{
"Name": "Standup",
"ItemId": "library-10",
"LibraryOptions": {
"PathInfos": []
}
}
]
""";
var client = new JellyfinApiClient(
new MemoryCache(new MemoryCacheOptions()),
Substitute.For<IJellyfinPathReplacementService>(),
Substitute.For<IFallbackMetadataProvider>(),
new SingleResponseHttpClientFactory(response),
Substitute.For<ILogger<JellyfinApiClient>>());
Either<BaseError, List<JellyfinLibrary>> result =
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
result.IsRight.ShouldBeTrue();
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
libraries.Count.ShouldBe(1);
libraries[0].Name.ShouldBe("Standup");
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
}
// Jellyfin serializes "no content type" as absent, empty or whitespace depending on version;
// all three mean mixed content, so all three must project identically.
[TestCase("\"CollectionType\": \"\",")]
[TestCase("\"CollectionType\": \" \",")]
public async Task Should_Project_Libraries_With_Blank_CollectionType_As_Mixed(string collectionTypeLine)
{
string response = $$"""
[
{
"Name": "Standup",
{{collectionTypeLine}}
"ItemId": "library-12",
"LibraryOptions": {
"PathInfos": []
}
}
]
""";
var client = new JellyfinApiClient(
new MemoryCache(new MemoryCacheOptions()),
Substitute.For<IJellyfinPathReplacementService>(),
Substitute.For<IFallbackMetadataProvider>(),
new SingleResponseHttpClientFactory(response),
Substitute.For<ILogger<JellyfinApiClient>>());
Either<BaseError, List<JellyfinLibrary>> result =
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
result.IsRight.ShouldBeTrue();
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
libraries.Count.ShouldBe(1);
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
}
// Guard: mixed must not become a catch-all. Jellyfin "music" (audio) libraries have no
// supported scanner, so they must keep falling through to None.
[Test]
public async Task Should_Not_Project_Unknown_CollectionTypes()
{
const string response = """
[
{
"Name": "Explo Discovery",
"CollectionType": "music",
"ItemId": "library-11",
"LibraryOptions": {
"PathInfos": []
}
}
]
""";
var client = new JellyfinApiClient(
new MemoryCache(new MemoryCacheOptions()),
Substitute.For<IJellyfinPathReplacementService>(),
Substitute.For<IFallbackMetadataProvider>(),
new SingleResponseHttpClientFactory(response),
Substitute.For<ILogger<JellyfinApiClient>>());
Either<BaseError, List<JellyfinLibrary>> result =
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
result.IsRight.ShouldBeTrue();
result.RightToSeq().Single().ShouldBeEmpty();
}
}
private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
@@ -0,0 +1,220 @@
using System.Buffers.Binary;
using ErsatzTV.Core.Images;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using NUnit.Framework;
using Shouldly;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics;
/// <summary>
/// The byte cap in <c>HttpRemoteImageFetcher</c> does not bound decoding: a decompression bomb
/// is tiny on the wire and enormous in memory. These pin the header-first check that does.
/// (ersatztv#511)
/// </summary>
[TestFixture]
public class RemoteImageDecodeLimitTests
{
private static readonly Uri ImageUri = new("https://example.com/logo.png");
[Test]
public async Task Should_Decode_A_Normal_Image()
{
await using MemoryStream stream = await RealPng(64, 32);
using Image image = await ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None);
image.Width.ShouldBe(64);
image.Height.ShouldBe(32);
}
// the bomb: a few dozen bytes on the wire, ~3.6 GB if decoded. it sails through the byte cap,
// the content-type check and the Content-Length reject -- only the header dimensions catch it.
[Test]
public async Task Should_Reject_An_Image_Whose_Declared_Dimensions_Are_A_Decompression_Bomb()
{
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
stream.Length.ShouldBeLessThan(100, "the point is that this is tiny on the wire");
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
() => ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None));
ex.Message.ShouldContain("pixel limit");
}
[Test]
public async Task Should_Accept_Dimensions_Exactly_At_The_Limit()
{
// 10000 x 5000 = 50,000,000 -- exactly the budget, so it must NOT be rejected. the decode
// then fails on the truncated body, which proves the check let it through. NOTE this test
// would also pass with the guard deleted entirely; deletion is covered by the bomb test
// above, and the boundary arithmetic by RemoteImageDecodeBudgetTests (ErsatzTV.Core.Tests).
await using MemoryStream stream = PngHeaderDeclaring(10000, 5000);
Exception ex = await Should.ThrowAsync<Exception>(
() => ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None));
ex.Message.ShouldNotContain("pixel limit");
}
// the retained-frame budget is INDEPENDENT of the decode budget: this source is trivial to
// decode (6 MP total) but retains ~5 GB of SKBitmap once every frame is scaled to 1080p
[Test]
public void Should_Reject_Cheap_Frames_That_Are_Expensive_Once_Scaled()
{
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(100, 100, 600, ImageUri));
InvalidOperationException ex = Should.Throw<InvalidOperationException>(
() => ImageElementBase.EnsureScaledFramesAffordable(600, 1920, 1080, ImageUri));
ex.Message.ShouldContain("pixel limit");
}
[Test]
public void Should_Allow_A_Scaled_Watermark_Sized_Animation()
{
// a 10%-width logo on a 1080p frame, animated
Should.NotThrow(() => ImageElementBase.EnsureScaledFramesAffordable(600, 192, 108, ImageUri));
}
// --- B1 regression: the header's frame count is a LIE for APNG ---
// ImageSharp 3.1.12 reports FrameMetadataCollection.Count == 0 for an APNG while the decoder
// produces every frame. A budget derived from that header count is enforced on a number the
// decoder does not honor — this exact payload shape, at 4000x4000, is ~134 KiB on the wire and
// ~36 GiB decoded. The bound therefore has to be imposed ON THE DECODER (DecoderOptions.
// MaxFrames) and re-verified against the real frame count. (ersatztv#511, second re-review.)
[Test]
public async Task Should_Reject_An_Animation_Whose_Header_Under_Reports_Its_Frames()
{
await using MemoryStream stream = Apng(64, 64, RemoteImageDecodeBudget.MaxRemoteFrames + 100);
// the premise: the header really does under-report, so a header-derived budget waves it through
stream.Position = 0;
ImageInfo info = await Image.IdentifyAsync(stream);
info.FrameMetadataCollection.Count.ShouldBe(0, "the APNG header under-reports; that is the whole point");
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(64, 64, info.FrameMetadataCollection.Count, ImageUri));
stream.Position = 0;
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
() => ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None));
ex.Message.ShouldContain("frame limit");
}
// ...and an animation within budget still decodes IN FULL -- the decoder cap must not silently
// truncate legitimate content by a frame
[Test]
public async Task Should_Decode_An_Animation_Within_Budget_Without_Truncating_It()
{
await using MemoryStream stream = Apng(64, 64, 300);
using Image image = await ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None);
image.Frames.Count.ShouldBe(300);
}
// H2 regression: a default `Image.Identify` throws InvalidImageContentException on most APNGs
// (measured: 13 of 16 shapes, including ones ImageSharp's own encoder wrote) even though
// `Image.Load` reads them back perfectly. 288x288 is one of the throwing shapes; 64x64 x300 --
// used by the tests above -- happens NOT to be, which is exactly why they could not see this.
// Without the MaxFrames=1 workaround on the Identify, every animated-PNG logo that worked
// before this change would be silently disabled. (ersatztv#511, fourth re-review.)
[Test]
public async Task Should_Decode_An_Apng_That_A_Default_Identify_Cannot_Read()
{
await using MemoryStream stream = Apng(288, 288, 60);
// the premise: a default Identify really does fail on this file
stream.Position = 0;
await Should.ThrowAsync<Exception>(() => Image.IdentifyAsync(stream));
stream.Position = 0;
using Image image = await ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None);
image.Width.ShouldBe(288);
image.Height.ShouldBe(288);
image.Frames.Count.ShouldBe(60);
}
/// <summary>A real multi-frame APNG. Small on the wire, many frames — the shape that matters.</summary>
private static MemoryStream Apng(int width, int height, int frames)
{
using var image = new Image<Rgba32>(width, height);
for (var i = 1; i < frames; i++)
{
image.Frames.CreateFrame();
}
var stream = new MemoryStream();
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
stream.Position = 0;
return stream;
}
// PNG chunk CRC-32 (IEEE, reflected). hand-rolled because the repo does not reference
// System.IO.Hashing, and ImageSharp validates the CRC of critical chunks like IHDR.
private static uint Crc32(ReadOnlySpan<byte> data)
{
uint crc = 0xFFFFFFFF;
foreach (byte b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
}
}
return crc ^ 0xFFFFFFFF;
}
/// <summary>A real, decodable PNG.</summary>
private static async Task<MemoryStream> RealPng(int width, int height)
{
using var image = new Image<Rgba32>(width, height);
var stream = new MemoryStream();
await image.SaveAsync(stream, new PngEncoder());
stream.Position = 0;
return stream;
}
/// <summary>
/// A PNG signature plus a single valid IHDR chunk declaring <paramref name="width" /> x
/// <paramref name="height" /> and nothing else — enough for Identify, far too little to
/// decode. This is what a decompression bomb looks like at the point we have to reject it.
/// </summary>
private static MemoryStream PngHeaderDeclaring(int width, int height)
{
var stream = new MemoryStream();
stream.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
var ihdr = new byte[17];
"IHDR"u8.CopyTo(ihdr);
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4), width);
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(8), height);
ihdr[12] = 8; // bit depth
ihdr[13] = 6; // color type: truecolor + alpha
ihdr[14] = 0; // compression
ihdr[15] = 0; // filter
ihdr[16] = 0; // interlace
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, 13);
stream.Write(length);
stream.Write(ihdr);
var crc = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(ihdr));
stream.Write(crc);
stream.Position = 0;
return stream;
}
}
@@ -0,0 +1,183 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using LanguageExt;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics;
/// <summary>
/// The degradation contract for a remote watermark image: whatever the fetcher throws, the
/// element disables itself and the stream survives. (ersatztv#511)
/// </summary>
[TestFixture]
public class WatermarkElementRemoteImageTests
{
private const string RemoteLogo = "https://example.com/logo.png";
private static readonly Exception[] FetchFailures =
[
new TimeoutException("timed out fetching remote image"),
new InvalidOperationException("remote image exceeds the byte limit"),
new HttpRequestException("no route to host")
];
[TestCaseSource(nameof(FetchFailures))]
public async Task Should_Disable_The_Watermark_When_The_Fetch_Fails(Exception failure)
{
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>()).Returns<Task<Stream>>(_ => throw failure);
var element = new WatermarkElement(
RemoteWatermarkOptions(),
fetcher,
Substitute.For<ILogger>());
// must not throw -- a killed graphics element must never propagate into the stream
await element.InitializeAsync(Context(), CancellationToken.None);
element.IsFinished.ShouldBeTrue();
}
// the counterpart: caller cancellation is not a fetch failure, but it must still not escape
// into the streaming pipeline as an unhandled exception
[Test]
public async Task Should_Disable_The_Watermark_When_The_Caller_Cancels()
{
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
.Returns<Task<Stream>>(_ => throw new OperationCanceledException());
var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For<ILogger>());
await element.InitializeAsync(Context(), CancellationToken.None);
element.IsFinished.ShouldBeTrue();
}
// a remote path really does route to the fetcher -- without this the tests above would pass
// even if LoadImage stopped recognising http(s) urls
[Test]
public async Task Should_Route_A_Remote_Path_Through_The_Fetcher()
{
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
.Returns<Task<Stream>>(_ => throw new TimeoutException());
var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For<ILogger>());
await element.InitializeAsync(Context(), CancellationToken.None);
await fetcher.Received(1).Fetch(new Uri(RemoteLogo), Arg.Any<CancellationToken>());
}
// ...and a local path must not touch the network at all
[Test]
public async Task Should_Not_Use_The_Fetcher_For_A_Local_Path()
{
var fetcher = Substitute.For<IRemoteImageFetcher>();
var options = new WatermarkOptions(Watermark(), "/no/such/logo.png", Option<int>.None);
var element = new WatermarkElement(options, fetcher, Substitute.For<ILogger>());
await element.InitializeAsync(Context(), CancellationToken.None);
await fetcher.DidNotReceive().Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>());
element.IsFinished.ShouldBeTrue();
}
// --- the RETENTION budget is wired in, and is remote-only (M4 from re-review) ---
//
// These two differ ONLY in the scale percent, so together they detect deletion of the
// `if (isRemoteUri) EnsureScaledFramesAffordable(...)` call site: without it the first case
// would succeed. The source is trivial to decode (300 x 64x64 = 1.2 MP, well inside the decode
// budget) but retains ~2.5 GB of SKBitmap once every frame is scaled to 1080p.
[Test]
public async Task Should_Disable_The_Watermark_When_Scaled_Frames_Blow_The_Retention_Budget()
{
var element = new WatermarkElement(
RemoteWatermarkOptions(widthPercent: 100),
FetcherReturning(Apng(64, 64, 300)),
Substitute.For<ILogger>());
await element.InitializeAsync(Context(), CancellationToken.None);
element.IsFinished.ShouldBeTrue();
}
[Test]
public async Task Should_Keep_The_Watermark_When_The_Same_Animation_Is_Scaled_Small()
{
var element = new WatermarkElement(
RemoteWatermarkOptions(widthPercent: 10),
FetcherReturning(Apng(64, 64, 300)),
Substitute.For<ILogger>());
await element.InitializeAsync(Context(), CancellationToken.None);
element.IsFinished.ShouldBeFalse();
}
private static IRemoteImageFetcher FetcherReturning(Stream stream)
{
var fetcher = Substitute.For<IRemoteImageFetcher>();
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>()).Returns(stream);
return fetcher;
}
private static MemoryStream Apng(int width, int height, int frames)
{
using var image = new Image<Rgba32>(width, height);
for (var i = 1; i < frames; i++)
{
image.Frames.CreateFrame();
}
var stream = new MemoryStream();
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
stream.Position = 0;
return stream;
}
private static WatermarkOptions RemoteWatermarkOptions(double widthPercent = 10) =>
new(Watermark(widthPercent), RemoteLogo, Option<int>.None);
private static ChannelWatermark Watermark(double widthPercent = 10) =>
new()
{
Name = "test",
Mode = ChannelWatermarkMode.Permanent,
Location = WatermarkLocation.BottomRight,
Size = WatermarkSize.Scaled,
WidthPercent = widthPercent,
HorizontalMarginPercent = 5,
VerticalMarginPercent = 5,
Opacity = 100
};
private static GraphicsEngineContext Context() =>
new(
"1",
null,
[],
[],
new Resolution { Width = 1920, Height = 1080 },
new Resolution { Width = 1920, Height = 1080 },
new FrameRate("30"),
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
TimeSpan.Zero,
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(1));
}
@@ -0,0 +1,179 @@
using System.Buffers;
using System.Net;
using System.Net.Http.Headers;
using ErsatzTV.Infrastructure.Streaming;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Streaming;
[TestFixture]
public class HttpRemoteImageFetcherTests
{
private static readonly Uri ImageUri = new("https://example.com/logo.png");
[Test]
public async Task Should_Return_The_Buffered_Body_On_Success()
{
byte[] payload = [1, 2, 3, 4, 5];
HttpRemoteImageFetcher fetcher = FetcherReturning(Ok(payload, "image/png"));
await using Stream result = await fetcher.Fetch(ImageUri, CancellationToken.None);
result.Position.ShouldBe(0);
var read = new byte[payload.Length];
await result.ReadExactlyAsync(read);
read.ShouldBe(payload);
}
[Test]
public async Task Should_Throw_On_An_Error_Status()
{
HttpRemoteImageFetcher fetcher = FetcherReturning(new HttpResponseMessage(HttpStatusCode.NotFound));
await Should.ThrowAsync<HttpRequestException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
}
// an html error page served with a 200 must never reach the decoder
[Test]
public async Task Should_Reject_A_Non_Image_Content_Type()
{
HttpRemoteImageFetcher fetcher = FetcherReturning(Ok([1, 2, 3], "text/html"));
await Should.ThrowAsync<InvalidOperationException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
}
// hosts that omit the header, and static file servers that default to octet-stream, are common
// enough that rejecting them would break working logos for no security gain -- ImageSharp
// decodes by magic bytes, and the wire-size + decode-budget caps are the real protection.
[TestCase(null)]
[TestCase("application/octet-stream")]
public async Task Should_Accept_A_Missing_Or_Generic_Content_Type(string mediaType)
{
HttpRemoteImageFetcher fetcher = FetcherReturning(Ok([1, 2, 3], mediaType));
await using Stream result = await fetcher.Fetch(ImageUri, CancellationToken.None);
result.Length.ShouldBe(3);
}
// the advertised length is the cheap reject: the body must not be pulled at all
[Test]
public async Task Should_Reject_An_Oversized_Content_Length_Without_Reading_The_Body()
{
var body = new TrackingStream(HttpRemoteImageFetcher.MaxImageBytes + 1);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
response.Content.Headers.ContentLength = HttpRemoteImageFetcher.MaxImageBytes + 1;
HttpRemoteImageFetcher fetcher = FetcherReturning(response);
await Should.ThrowAsync<InvalidOperationException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
body.BytesRead.ShouldBe(0);
}
// ...and a host that lies about (or omits) Content-Length is still capped, because the copy
// itself counts bytes. (the DECODE bomb -- small on the wire, huge in memory -- is a different
// problem, bounded by the decode/retention budgets in ImageElementBase, not by this.)
[Test]
public async Task Should_Cap_A_Body_That_Does_Not_Advertise_Its_Length()
{
var body = new ChunkedZeroStream(HttpRemoteImageFetcher.MaxImageBytes * 2);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
response.Content.Headers.ContentLength = null;
HttpRemoteImageFetcher fetcher = FetcherReturning(response);
await Should.ThrowAsync<InvalidOperationException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
// proves this went through the COPY cap and not the Content-Length early reject: the body
// really was read, and it stopped at the limit rather than draining all 20 MiB.
body.Position.ShouldBeGreaterThan(0);
// the over-read bound is ONE RENTED BUFFER, and ArrayPool.Rent(81920) actually hands back
// 131072 -- deriving it keeps this honest if the request size or the pool bucketing changes.
byte[] rented = ArrayPool<byte>.Shared.Rent(81920);
ArrayPool<byte>.Shared.Return(rented);
body.Position.ShouldBeLessThanOrEqualTo(HttpRemoteImageFetcher.MaxImageBytes + rented.Length);
}
// a host that accepts the connection and then hangs must not stall stream startup forever
[Test]
public async Task Should_Time_Out_A_Hanging_Host()
{
var fetcher = new HttpRemoteImageFetcher(
new StubHttpClientFactory(new HangingHttpMessageHandler()),
new RecyclableMemoryStreamManager(),
Substitute.For<ILogger<HttpRemoteImageFetcher>>(),
TimeSpan.FromMilliseconds(100));
await Should.ThrowAsync<TimeoutException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
}
// THE headline claim: under ResponseHeadersRead the body read falls outside HttpClient.Timeout,
// so a host that returns headers promptly and then drips the body must still hit our deadline.
// the hanging-host test above only covers the pre-headers case, which a plain HttpClient.Timeout
// would already bound -- this is the one that pins the actual design.
[Test]
public async Task Should_Time_Out_A_Slow_Drip_Body()
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new SlowDripStream())
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
var fetcher = new HttpRemoteImageFetcher(
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
new RecyclableMemoryStreamManager(),
Substitute.For<ILogger<HttpRemoteImageFetcher>>(),
TimeSpan.FromMilliseconds(200));
await Should.ThrowAsync<TimeoutException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
}
[Test]
public async Task Should_Throw_On_Transport_Failure()
{
var fetcher = new HttpRemoteImageFetcher(
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))),
new RecyclableMemoryStreamManager(),
Substitute.For<ILogger<HttpRemoteImageFetcher>>());
await Should.ThrowAsync<HttpRequestException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
}
// caller cancellation (shutdown / client disconnect) is a genuine signal and must stay an
// OperationCanceledException rather than being relabelled as our timeout
[Test]
public async Task Should_Propagate_Caller_Cancellation()
{
var fetcher = new HttpRemoteImageFetcher(
new StubHttpClientFactory(new HangingHttpMessageHandler()),
new RecyclableMemoryStreamManager(),
Substitute.For<ILogger<HttpRemoteImageFetcher>>());
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => fetcher.Fetch(ImageUri, cts.Token));
}
private static HttpResponseMessage Ok(byte[] payload, string mediaType)
{
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(payload) };
response.Content.Headers.ContentType = mediaType is null ? null : new MediaTypeHeaderValue(mediaType);
return response;
}
private static HttpRemoteImageFetcher FetcherReturning(HttpResponseMessage response) =>
new(
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
new RecyclableMemoryStreamManager(),
Substitute.For<ILogger<HttpRemoteImageFetcher>>());
}
@@ -0,0 +1,164 @@
using System.Net;
using ErsatzTV.Infrastructure.Streaming;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Streaming;
[TestFixture]
public class HttpRemoteStreamProberTests
{
private const string Url = "http://localhost:8409/media/jellyfin/abc123";
[Test]
public async Task Should_Report_Unavailable_On_404_From_The_Media_Server()
{
// a media-server 404 arrives after our /media/... endpoint redirected, so the response's
// final request uri is the media server's, not the probe url
HttpRemoteStreamProber prober = ProberReturning(
HttpStatusCode.NotFound,
finalUri: "http://jellyfin:8096/Videos/abc123/stream?static=true");
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeFalse();
}
// ersatztv#473 review finding: our OWN /media/{provider}/... endpoint 404s when the media source
// is unconfigured or momentarily missing. Failing closed there would blank every item on that
// source, which is exactly what the fail-open contract exists to prevent.
[Test]
public async Task Should_Fail_Open_On_404_That_Was_Not_Redirected()
{
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: Url);
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// a plex key can contain spaces/unicode; pin that an un-redirected 404 on such a url still fails
// OPEN. (This passes against a naive string comparison too - Uri.ToString() unescapes - so it
// guards the behaviour, not the implementation choice.)
[Test]
public async Task Should_Fail_Open_On_404_For_An_Unredirected_Url_Needing_Escaping()
{
const string plexUrl = "http://localhost:8409/media/plex/1/library/parts/1/a file.mkv";
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: plexUrl);
bool result = await prober.IsAvailable(plexUrl, CancellationToken.None);
result.ShouldBeTrue();
}
[TestCase(HttpStatusCode.OK)]
[TestCase(HttpStatusCode.PartialContent)]
[TestCase(HttpStatusCode.NoContent)]
public async Task Should_Report_Available_On_Success(HttpStatusCode statusCode)
{
HttpRemoteStreamProber prober = ProberReturning(statusCode);
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// the fail-open contract: a probe that cannot answer must never block a tune that would
// otherwise have worked. these cases exist so a future refactor can't silently invert it.
[TestCase(HttpStatusCode.InternalServerError)]
[TestCase(HttpStatusCode.BadGateway)]
[TestCase(HttpStatusCode.Unauthorized)]
[TestCase(HttpStatusCode.Forbidden)]
public async Task Should_Fail_Open_On_Other_Status_Codes(HttpStatusCode statusCode)
{
HttpRemoteStreamProber prober = ProberReturning(statusCode);
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// a server that ignores `Range: bytes=0-0` answers 200 with the WHOLE FILE. The probe must not
// read it -- buffering a video on the streaming hot path would be far worse than the aborted
// socket the drain was added to avoid. (Review finding against the first fix commit.)
[Test]
public async Task Should_Not_Read_The_Body_When_The_Server_Ignores_The_Range_Request()
{
var body = new TrackingStream(64 * 1024 * 1024);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
body.BytesRead.ShouldBe(0);
}
// the counterpart: when the server DID honour the range, the one byte is read so the connection
// goes back to the pool rather than being aborted
[Test]
public async Task Should_Drain_The_Single_Byte_When_The_Server_Honours_The_Range_Request()
{
var body = new TrackingStream(1);
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new StreamContent(body)
};
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
body.BytesRead.ShouldBe(1);
}
[Test]
public async Task Should_Fail_Open_On_Transport_Failure()
{
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
[Test]
public async Task Should_Fail_Open_On_Timeout()
{
var prober = new HttpRemoteStreamProber(
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new TaskCanceledException("timed out"))),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
bool result = await prober.IsAvailable(Url, CancellationToken.None);
result.ShouldBeTrue();
}
// caller cancellation (shutdown / client disconnect) is a genuine signal, NOT a probe failure --
// swallowing it would let the handler go on building an ffmpeg command on a dead token.
[Test]
public async Task Should_Propagate_Caller_Cancellation()
{
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.OK);
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => prober.IsAvailable(Url, cts.Token));
}
private static HttpRemoteStreamProber ProberReturning(HttpStatusCode statusCode, string finalUri = null) =>
new(
new StubHttpClientFactory(new StatusCodeHttpMessageHandler(statusCode, finalUri)),
Substitute.For<ILogger<HttpRemoteStreamProber>>());
}
@@ -0,0 +1,186 @@
using System.Diagnostics;
using System.Net;
namespace ErsatzTV.Infrastructure.Tests.Streaming;
/// <summary>
/// Shared fakes for the HTTP-backed streaming services. Extracted from
/// <see cref="HttpRemoteStreamProberTests" /> when <see cref="HttpRemoteImageFetcherTests" />
/// needed the same harness. (ersatztv#511)
/// </summary>
internal sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
internal sealed class StatusCodeHttpMessageHandler(HttpStatusCode statusCode, string finalUri = null)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// HttpClient rewrites RequestMessage.RequestUri to the final hop when it follows a
// redirect; finalUri lets a test stand in for "the media server answered this".
if (finalUri is not null)
{
request.RequestUri = new Uri(finalUri);
}
return Task.FromResult(new HttpResponseMessage(statusCode) { RequestMessage = request });
}
}
internal sealed class FixedResponseHttpMessageHandler(HttpResponseMessage response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
response.RequestMessage = request;
return Task.FromResult(response);
}
}
/// <summary>A readable stream that records how many bytes were actually pulled from it.</summary>
internal sealed class TrackingStream(long length) : Stream
{
public int BytesRead { get; private set; }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => length;
public override long Position
{
get => BytesRead;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
if (BytesRead >= length)
{
return 0;
}
int toRead = (int)Math.Min(count, length - BytesRead);
Array.Clear(buffer, offset, toRead);
BytesRead += toRead;
return toRead;
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
/// <summary>
/// A readable stream that returns headers-worth of data instantly and then drips forever —
/// the case that <see cref="HttpCompletionOption.ResponseHeadersRead" /> leaves outside
/// <see cref="HttpClient.Timeout" />.
/// </summary>
internal sealed class SlowDripStream : Stream
{
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => 0;
set => throw new NotSupportedException();
}
public override async ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
throw new UnreachableException();
}
public override int Read(byte[] buffer, int offset, int count) =>
ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
internal sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromException<HttpResponseMessage>(exception);
}
/// <summary>
/// A handler that never completes until the request is cancelled — stands in for a host that
/// accepts the connection and then hangs.
/// </summary>
internal sealed class HangingHttpMessageHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
throw new UnreachableException();
}
}
/// <summary>
/// A readable stream that yields <paramref name="length" /> bytes but only ever a little at a
/// time, so a size cap has to be enforced during the copy rather than from Content-Length.
/// </summary>
internal sealed class ChunkedZeroStream(long length, int chunkSize = 4096) : Stream
{
private long _position;
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => length;
public override long Position
{
get => _position;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
if (_position >= length)
{
return 0;
}
int toRead = (int)Math.Min(Math.Min(count, chunkSize), length - _position);
Array.Clear(buffer, offset, toRead);
_position += toRead;
return toRead;
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -24,5 +24,8 @@ public class FFmpegProfileConfiguration : IEntityTypeConfiguration<FFmpegProfile
builder.Property(p => p.NormalizeColors)
.HasDefaultValue(true);
builder.Property(p => p.QsvPreferNativeDecoder)
.HasDefaultValue(true);
}
}
+70 -7
View File
@@ -1,8 +1,9 @@
using System.Globalization;
using System.Globalization;
using System.Reflection;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.FFmpeg.State;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data;
@@ -131,13 +132,17 @@ public static class DbInitializer
await context.SaveChangesAsync(cancellationToken);
}
await SeedChannelTemplates(context, cancellationToken);
int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken);
await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId);
// TODO: create looping static image that mentions configuring via web
return Unit.Default;
}
private static async Task SeedChannelTemplates(TvContext context, CancellationToken cancellationToken)
private static async Task SeedChannelTemplates(
TvContext context,
CancellationToken cancellationToken,
int? channelBugWatermarkId)
{
if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) &&
await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
@@ -162,7 +167,8 @@ public static class DbInitializer
ChannelMusicVideoCreditsMode.None,
ChannelSongVideoMode.Default,
shuffleScheduleItems: false,
randomStartPoint: false),
randomStartPoint: false,
channelBugWatermarkId),
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
@@ -177,7 +183,8 @@ public static class DbInitializer
ChannelMusicVideoCreditsMode.GenerateSubtitles,
ChannelSongVideoMode.WithProgress,
shuffleScheduleItems: true,
randomStartPoint: true),
randomStartPoint: true,
channelBugWatermarkId),
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
@@ -185,6 +192,60 @@ public static class DbInitializer
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
}
// A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's
// own logo artwork at render time (WatermarkSelector), so one row makes every channel use its
// own logo as its on-screen bug.
//
// Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem
// flag, and Initialize runs on every startup, so a name-only guard would resurrect the row
// forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned
// one) also sets the marker — adopt, never overwrite.
private static async Task<int?> SeedChannelBugWatermark(
TvContext context,
CancellationToken cancellationToken)
{
string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key;
bool alreadySeeded = await context.ConfigElements
.AnyAsync(c => c.Key == seededKey, cancellationToken);
ChannelWatermark existing = await context.ChannelWatermarks
.FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken);
if (alreadySeeded)
{
return existing?.Id;
}
if (existing is null)
{
existing = new ChannelWatermark
{
Name = "Channel Bug",
Mode = ChannelWatermarkMode.Permanent,
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
Image = null,
Location = WatermarkLocation.TopLeft,
Size = WatermarkSize.Scaled,
WidthPercent = 5.0,
HorizontalMarginPercent = 1.0,
VerticalMarginPercent = 1.0,
FrequencyMinutes = 0,
DurationSeconds = 0,
Opacity = 80,
PlaceWithinSourceContent = false,
ZIndex = 0
};
await context.ChannelWatermarks.AddAsync(existing, cancellationToken);
}
await context.ConfigElements.AddAsync(
new ConfigElement { Key = seededKey, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return existing.Id;
}
private static async Task<FFmpegProfile> GetDefaultFFmpegProfile(
TvContext context,
CancellationToken cancellationToken)
@@ -237,7 +298,8 @@ public static class DbInitializer
ChannelMusicVideoCreditsMode musicVideoCreditsMode,
ChannelSongVideoMode songVideoMode,
bool shuffleScheduleItems,
bool randomStartPoint) =>
bool randomStartPoint,
int? watermarkId) =>
new()
{
Name = name,
@@ -260,6 +322,7 @@ public static class DbInitializer
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
ShuffleScheduleItems = shuffleScheduleItems,
RandomStartPoint = randomStartPoint,
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
WatermarkId = watermarkId
};
}
@@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
@@ -387,6 +387,53 @@ public class EmbyTelevisionRepository(
return ids;
}
// #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the
// already-scoped show ids needs no provider join.
public async Task<List<int>> FlagFileNotFoundSeasonsForShows(
List<int> showIds,
CancellationToken cancellationToken) =>
await FlagFileNotFoundByParent(
"SELECT Id FROM Season WHERE ShowId IN @ParentIds",
showIds,
cancellationToken);
// #476: Episode.SeasonId is on the base Episode table.
public async Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
List<int> seasonIds,
CancellationToken cancellationToken) =>
await FlagFileNotFoundByParent(
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
seasonIds,
cancellationToken);
private async Task<List<int>> FlagFileNotFoundByParent(
string selectSql,
List<int> parentIds,
CancellationToken cancellationToken)
{
if (parentIds.Count == 0)
{
return [];
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> ids = await dbContext.Connection.QueryAsync<int>(
new CommandDefinition(
selectSql,
parameters: new { ParentIds = parentIds },
cancellationToken: cancellationToken))
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
new CommandDefinition(
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
parameters: new { Ids = ids },
cancellationToken: cancellationToken));
return ids;
}
public async Task<List<int>> FlagFileNotFoundEpisodes(
EmbyLibrary library,
List<string> episodeItemIds,
@@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
@@ -198,7 +198,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
await UpdateEpisode(dbContext, existing, item, cancellationToken);
result = new MediaItemScanResult<JellyfinEpisode>(existing)
{ IsAdded = false, IsUpdated = true };
{ IsAdded = false, IsUpdated = true };
}
else
{
@@ -421,6 +421,53 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
return ids;
}
// #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the
// already-scoped show ids needs no provider join.
public async Task<List<int>> FlagFileNotFoundSeasonsForShows(
List<int> showIds,
CancellationToken cancellationToken) =>
await FlagFileNotFoundByParent(
"SELECT Id FROM Season WHERE ShowId IN @ParentIds",
showIds,
cancellationToken);
// #476: Episode.SeasonId is on the base Episode table.
public async Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
List<int> seasonIds,
CancellationToken cancellationToken) =>
await FlagFileNotFoundByParent(
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
seasonIds,
cancellationToken);
private async Task<List<int>> FlagFileNotFoundByParent(
string selectSql,
List<int> parentIds,
CancellationToken cancellationToken)
{
if (parentIds.Count == 0)
{
return [];
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> ids = await dbContext.Connection.QueryAsync<int>(
new CommandDefinition(
selectSql,
parameters: new { ParentIds = parentIds },
cancellationToken: cancellationToken))
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
new CommandDefinition(
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
parameters: new { Ids = ids },
cancellationToken: cancellationToken));
return ids;
}
public async Task<List<int>> FlagFileNotFoundEpisodes(
JellyfinLibrary library,
List<string> episodeItemIds,
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -169,11 +169,16 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
// load from db or create new folder
LibraryFolder knownFolder = await libraryPath.LibraryFolders
.Filter(f => f.Path == folder && f.LibraryPathId == libraryPath.Id)
.HeadOrNone()
.IfNoneAsync(CreateNewFolder(libraryPath, maybeParentFolder, folder));
// load from db or create new folder. Look the folder up by (LibraryPathId, Path) rather than
// reading libraryPath.LibraryFolders: that navigation collection is only eager-loaded on the
// local scan path (via GetLibrary) and is null on the remote (Jellyfin) sync path, which used
// to NRE every Jellyfin music-video scan here (ersatztv#488). The local scanners already hit
// the db once per folder via GetParentFolderId, so this adds no new query pattern.
LibraryFolder knownFolder = await dbContext.LibraryFolders
.AsNoTracking()
.Filter(f => f.LibraryPathId == libraryPath.Id && f.Path == folder)
.FirstOrDefaultAsync()
?? CreateNewFolder(libraryPath, maybeParentFolder, folder);
// update parent folder if not present
foreach (int parentFolder in maybeParentFolder)
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -465,6 +465,53 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
return ids;
}
// #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the
// already-scoped show ids needs no provider join.
public async Task<List<int>> FlagFileNotFoundSeasonsForShows(
List<int> showIds,
CancellationToken cancellationToken) =>
await FlagFileNotFoundByParent(
"SELECT Id FROM Season WHERE ShowId IN @ParentIds",
showIds,
cancellationToken);
// #476: Episode.SeasonId is on the base Episode table.
public async Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
List<int> seasonIds,
CancellationToken cancellationToken) =>
await FlagFileNotFoundByParent(
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
seasonIds,
cancellationToken);
private async Task<List<int>> FlagFileNotFoundByParent(
string selectSql,
List<int> parentIds,
CancellationToken cancellationToken)
{
if (parentIds.Count == 0)
{
return [];
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> ids = await dbContext.Connection.QueryAsync<int>(
new CommandDefinition(
selectSql,
parameters: new { ParentIds = parentIds },
cancellationToken: cancellationToken))
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
new CommandDefinition(
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
parameters: new { Ids = ids },
cancellationToken: cancellationToken));
return ids;
}
public async Task<List<int>> FlagFileNotFoundEpisodes(
PlexLibrary library,
List<string> episodeItemIds,
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
@@ -8,7 +8,14 @@ namespace ErsatzTV.Infrastructure.Health;
public class HealthCheckService : IHealthCheckService
{
private const string CacheKey = "healthcheck.summary";
private const string SummaryCacheKey = "healthcheck.summary";
private const string ResultsCacheKey = "healthcheck.results";
// Health checks shell out to ffmpeg/ffprobe (4 of the 14 checks) on every run, so a bare
// GET /api/v1/health spawns ~4 subprocesses per request. Cache the full result list for a
// short window so repeated polls (a status widget, an MCP client, monitoring) reuse it; an
// explicit refresh (forceRefresh) bypasses and repopulates. See docs/decisions.md 2026-07-19 (#431).
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
private readonly List<IHealthCheck> _checks; // ReSharper disable SuggestBaseTypeForParameterInConstructor
private readonly IMemoryCache _memoryCache;
@@ -56,8 +63,13 @@ public class HealthCheckService : IHealthCheckService
];
}
public async Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken)
public async Task<List<HealthCheckResult>> PerformHealthChecks(bool forceRefresh, CancellationToken cancellationToken)
{
if (!forceRefresh && _memoryCache.TryGetValue(ResultsCacheKey, out List<HealthCheckResult> cached) && cached is not null)
{
return cached;
}
List<HealthCheckResult> result = await _checks.Map(c =>
{
var failedResult = new HealthCheckResult(
@@ -75,7 +87,8 @@ public class HealthCheckService : IHealthCheckService
result.Count(x => x.Status is HealthCheckStatus.Warning),
result.Count(x => x.Status is HealthCheckStatus.Fail));
_memoryCache.Set(CacheKey, summary);
_memoryCache.Set(ResultsCacheKey, result, CacheTtl);
_memoryCache.Set(SummaryCacheKey, summary);
await _mediator.Publish(summary, cancellationToken);
@@ -83,7 +96,7 @@ public class HealthCheckService : IHealthCheckService
}
public HealthCheckSummary GetHealthCheckSummary() =>
_memoryCache.Get<HealthCheckSummary>(CacheKey) ?? new HealthCheckSummary(0, 0);
_memoryCache.Get<HealthCheckSummary>(SummaryCacheKey) ?? new HealthCheckSummary(0, 0);
private HealthCheckResult LogAndReturn(Exception ex, HealthCheckResult failedResult)
{
@@ -0,0 +1,83 @@
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.Images;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Images;
public class RemoteImageValidator : IRemoteImageValidator
{
public async Task Validate(Stream stream, Uri uri, CancellationToken cancellationToken)
{
using Image _ = await DecodeAndValidate(stream, uri, cancellationToken);
}
/// <summary>
/// Decodes a remote image only after the header says decoding it is affordable.
/// </summary>
/// <remarks>
/// The fetcher's byte cap does NOT bound this: a decompression bomb is small on the wire and
/// huge in memory. A 4 KB PNG can declare 30000x30000 (~3.6 GB), and a 60 KiB GIF can
/// declare 2500x2500 across 600 frames (~14 GiB). The budget is therefore on the PRODUCT of
/// dimensions and frames, read from the header before the decoder allocates.
/// Local images are deliberately not checked — they are files an operator put on disk, not
/// bytes an arbitrary host returned. (ersatztv#511)
/// </remarks>
public static async Task<Image> DecodeAndValidate(Stream stream, Uri uri, CancellationToken cancellationToken)
{
if (!stream.CanSeek)
{
// Identify consumes the stream, so the decode below needs to rewind it. Fail with the
// real reason rather than letting Position throw NotSupportedException, which the
// caller's blanket catch would report as a generic initialization failure.
throw new InvalidOperationException(
$"Remote image {uri} was returned on a non-seekable stream; IRemoteImageFetcher must "
+ "return a fully buffered, seekable stream");
}
// MaxFrames = 1 on the IDENTIFY is not a limit, it is a workaround: a default Identify
// throws InvalidImageContentException on most APNGs — including files ImageSharp's own
// PngEncoder wrote, which Image.Load then reads back perfectly (measured: 13 of 16 shapes).
// Without this, adding the header pre-pass would silently disable every animated-PNG logo
// that worked before this change. Only Width/Height are read below, and those stay correct.
ImageInfo info = await Image.IdentifyAsync(
new DecoderOptions { MaxFrames = 1 },
stream,
cancellationToken);
// DIMENSIONS from the header are trustworthy; the FRAME COUNT is not, and is deliberately
// not used as a budget input. Measured on ImageSharp 3.1.12: an APNG reports
// FrameMetadataCollection.Count == 0 while the decoder happily produces 600 frames, so a
// header-derived frame budget is enforced on a number the decoder does not honor — a
// 134 KiB file decodes to ~36 GiB. (Second adversarial re-review; ersatztv#511.)
RemoteImageDecodeBudget.EnsureDimensionsAffordable(info.Width, info.Height, uri);
int affordableFrames = RemoteImageDecodeBudget.AffordableFrames(info.Width, info.Height);
stream.Position = 0;
// MaxFrames is enforced BY THE DECODER, so it holds whatever the header claimed — measured
// as honored by every animated decoder here (APNG, GIF, WebP, TIFF). Ask for two more than
// the budget allows so that an animation exactly AT the limit still decodes in full, while
// anything over it is present in the decoded image for the post-decode check below to
// reject. Slop is at most two frames: MaxFrames = N yields N frames for GIF/WebP/TIFF but
// N-1 for APNG, so the exact count varies by format and only the upper bound matters.
var decoderOptions = new DecoderOptions { MaxFrames = (uint)(affordableFrames + 2) };
Image image = await Image.LoadAsync(decoderOptions, stream, cancellationToken);
try
{
// re-verify against REALITY rather than against the header. this is the check that
// actually holds; everything above it only avoids decoding when we can tell in advance.
RemoteImageDecodeBudget.EnsureDecodeAffordable(image.Width, image.Height, image.Frames.Count, uri);
return image;
}
catch
{
image.Dispose();
throw;
}
}
}

Some files were not shown because too many files have changed in this diff Show More