Files
ersatztv/docs/decisions/records/media/remote-stream-probe.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
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 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

6.1 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
media.remote-stream-probe 2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473) active 2026-07-19 none none `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. remote-stream probing, fail-open/fail-closed, exit-8 ffmpeg loop · paths: `GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, `IRemoteStreamProber` · issues: #473, #350, #480 `HttpRemoteStreamProberTests` (`Should_Fail_Open_*` / `Should_Fail_Open_On_404_That_Was_Not_Redirected`)

GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath now probes the Plex/Jellyfin/Emby remote-stream URL via the new IRemoteStreamProber seam before returning it, and on a 404 from the media server returns the new PlayoutItemNotAvailableFromMediaServer error instead of a playable path.

  • The bug this closes. The method's local branch checked _fileSystem.File.Exists(path), but the three 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", ffmpeg was launched against a URL that 404s and died with exit 8 — landing 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, so a dead item produces a repeating error card for its whole slot (~22 min for an episode). The fix restores the method's own invariant — every PlayoutItemWithPath it returns has been checked for existence — so the failure now returns an error rather than a playable URL, and the handler's error path sizes the card to run until the next playout item.
  • What the new case label does and does not do (a review corrected an earlier draft of this entry): the skip-to-next-item sizing comes from maybeDuration/finish, computed before the switch — the default: arm already had it. Adding case PlayoutItemNotAvailableFromMediaServer: alongside PlayoutItemDoesNotExistOnDisk changes only the caption on the card (the real error text instead of "Channel is Offline"). Worth having, but not where the fix lives. A handler test asserts that message specifically, so the label cannot silently decay into dead weight.
  • Scope: the generated-playout path only. All three remote branches of ValidatePlayoutItemPath are covered. ExternalJsonPlayoutItemProvider builds its own /media/plex/... URL and its result is assigned without passing through ValidatePlayoutItemPath, so external-JSON channels keep the old behaviour — tracked as #480 rather than silently claimed as fixed. The troubleshooting and subtitle-extraction paths build the same URLs and are deliberately left unprobed: troubleshooting should surface the raw ffmpeg failure, and a subtitle-extraction miss is not a channel outage.
  • Fail-open on everything except a redirected 404. A timeout, a 5xx, an auth error or a transport failure returns available — and so does a 404 that arrived without a redirect. ErsatzTV's own InternalController returns NotFound when the media source is unconfigured or momentarily missing, so honouring that would fail closed for every item on that source; the first draft probed for "any 404", which made this contract untrue. Only a 404 reached after the redirect to the media server is evidence the item is gone. Pinned by tests (Should_Fail_Open_*, Should_Fail_Open_On_404_That_Was_Not_Redirected) so a later refactor can't quietly invert it. Caller cancellation is not swallowed — it propagates, because a shutdown is a genuine signal, not a probe failure.
  • No ConfigElement toggle. The behaviour strictly dominates the status quo, and the probe is bounded by a 2s linked-CTS timeout (not HttpClient.Timeout, which defaults to 100s). A toggle would be surface for a switch nobody has a reason to flip. Not yet measured: on an install without path replacement every item selection pays this probe, and a slow media server could cost up to the full 2s. That is a worst case rather than a typical one, but it has not been measured against #350's cold-start budget — measure before assuming it is noise.
  • Rejected — resizing the HlsSessionWorker retry loop to skip the item on any ffmpeg failure. It buys the viewer nothing the above doesn't (the slot is dead either way) and cannot distinguish "this item is dead" from "this transcoder hiccupped". Prod shows real transient channel-wide failures (VAAPI hwupload -22 / exit 234), and retrying those is correct; converting them into whole-item blackouts is a regression. The retry loop is deliberately left intact — it also remains the backstop for the probe's TOCTOU window (media can vanish between a 200 probe and ffmpeg's own request).
  • Rejected — writing MediaItemState from the streaming path. Only the scanner writes State, and breaking that ownership wouldn't even have fixed this: the failing item is RemoteOnly, which PlayoutBuilder's PlayoutSkipMissingItems skip does not exclude. On Jellyfin (ServerSupportsRemoteStreaming) RemoteOnly is the normal state for every item when path replacement isn't configured, so it carries no signal about playability — never key availability decisions off it.
  • Deferred — HEAD instead of GET. The probe sends GET with Range: bytes=0-0, which on /Videos/{id}/stream?static=true is a real (if minimal) playback request and may register a session or touch play-state on some media-server versions. GET was chosen because HEAD support varies across media servers — that is a reasonable prior but an unverified one. A HEAD-with-GET-fallback would avoid the side effect; deferred rather than guessed at, since it trades a known-working request for an untested one.