using System.Net; using System.Net.Http.Headers; using ErsatzTV.Core.Interfaces.Streaming; using Microsoft.Extensions.Logging; namespace ErsatzTV.Infrastructure.Streaming; /// /// Probes a media-server remote-stream URL over HTTP. /// /// /// Deliberately fail-open: the only outcome that reports the media as gone is a 404 that came /// from the media server itself (i.e. arrived after our /media/{provider}/... endpoint /// redirected). A timeout, a transport failure, any other status, or a 404 raised by ErsatzTV's /// own endpoint all report available, so a probe that cannot answer never turns a tune that /// would have worked into an error card. (ersatztv#473) /// public class HttpRemoteStreamProber( IHttpClientFactory httpClientFactory, ILogger logger) : IRemoteStreamProber { private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2); public async Task 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 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) { // only the MEDIA SERVER's 404 is evidence that the item is gone. our own // /media/{provider}/... endpoint also returns 404 when the media source is // unconfigured or momentarily missing (InternalController maps a failed // connection-parameter lookup to NotFound), and treating that as "gone" would fail // CLOSED for every item on that source. A media-server 404 always arrives after a // redirect, so an un-redirected 404 came from us and must fail open. if (WasRedirected(response, url)) { logger.LogWarning("Media server reported 404 for remote stream {Url}", url); return false; } logger.LogDebug( "Probe of {Url} returned 404 without redirecting to a media server; assuming the " + "item is available rather than failing closed on our own endpoint", url); return true; } // drain the single byte we asked for so the connection goes back to the pool instead of // being aborted when the unread response stream is disposed await response.Content.ReadAsByteArrayAsync(timeoutCts.Token); return true; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // the CALLER cancelled (shutdown / client disconnect). that is a genuine signal, not a // probe failure, so it must propagate rather than be swallowed as fail-open. throw; } 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; } } private static bool WasRedirected(HttpResponseMessage response, string probeUrl) { Uri finalUri = response.RequestMessage?.RequestUri; return finalUri is not null && !string.Equals(finalUri.ToString(), probeUrl, StringComparison.Ordinal); } }