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>
56 lines
2.1 KiB
C#
56 lines
2.1 KiB
C#
using System.Net;
|
|
using ErsatzTV.Core.Interfaces.Streaming;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Infrastructure.Streaming;
|
|
|
|
/// <summary>
|
|
/// Probes a media-server remote-stream URL over HTTP.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately fail-open: only a definitive HTTP 404 reports the media as gone. A probe that
|
|
/// times out, errors, or returns any other status must never turn a tune that would have worked
|
|
/// into an error card, so every other outcome reports available. (ersatztv#473)
|
|
/// </remarks>
|
|
public class HttpRemoteStreamProber(
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<HttpRemoteStreamProber> logger) : IRemoteStreamProber
|
|
{
|
|
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2);
|
|
|
|
public async Task<bool> IsAvailable(string url, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(ProbeTimeout);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
|
|
// ask for a single byte; media servers vary in their HEAD support, and this exercises the
|
|
// same redirect chain ffmpeg will follow
|
|
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
|
|
|
|
using HttpClient client = httpClientFactory.CreateClient();
|
|
using HttpResponseMessage response = await client.SendAsync(
|
|
request,
|
|
HttpCompletionOption.ResponseHeadersRead,
|
|
timeoutCts.Token);
|
|
|
|
if (response.StatusCode is HttpStatusCode.NotFound)
|
|
{
|
|
logger.LogWarning("Media server reported 404 for remote stream {Url}", url);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// fail open - a probe failure is not evidence that the media is gone
|
|
logger.LogDebug(ex, "Unable to probe remote stream {Url}; assuming it is available", url);
|
|
return true;
|
|
}
|
|
}
|
|
}
|