---
key: media.remote-stream-probe
title: '2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)'
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: '`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.'
signals: 'remote-stream probing, fail-open/fail-closed, exit-8 ffmpeg loop · paths: `GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, `IRemoteStreamProber` · issues: #473, #350, #480'
mechanics: '`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.
