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; } // return the connection to the pool instead of aborting it by disposing an unread // stream - but ONLY where the server honoured the range, i.e. the body really is one // byte. A server that ignores `Range` answers 200 with the WHOLE FILE, and draining that // would download at line rate into memory on the streaming hot path, defeating the // ResponseHeadersRead above. There, abort the socket - much the cheaper evil. if (response.StatusCode is HttpStatusCode.PartialContent) { var singleByte = new byte[1]; Stream body = await response.Content.ReadAsStreamAsync(timeoutCts.Token); await body.ReadAsync(singleByte, 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; if (finalUri is null || !Uri.TryCreate(probeUrl, UriKind.Absolute, out Uri requestedUri)) { // can't tell where the 404 came from; fail open rather than guess return false; } // compare parsed Uris rather than strings. Uri.Equals compares normalized components, so it // can't mistake an escaping/casing difference for a redirect and fail CLOSED - the exact // failure this check exists to prevent. (A string compare on Uri.ToString() happens to agree // for our machine-generated URLs, since ToString unescapes; this is defense in depth, not a // fix for an observed bug.) return !Uri.Equals(finalUri, requestedUri); } }