Defense in depth on the redirect detector: Uri.Equals compares normalized components, so an escaping/casing difference can't be mistaken for a redirect and fail CLOSED -- the exact failure the check exists to prevent. A plex key can contain spaces or unicode. Honest note: this is NOT a fix for an observed bug. I wrote a test claiming to pin it, then ran the negative control and the test passed against the string comparison too -- Uri.ToString() unescapes, so both forms agree for our machine-generated URLs. The test was vacuous as written. It is kept, retitled and re-commented to describe what it actually guards (an un-redirected 404 on an escaping-sensitive url fails open), and the code comment says plainly that this is defense in depth rather than a repair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
4.6 KiB
C#
102 lines
4.6 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
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: the only outcome that reports the media as gone is a 404 that came
|
|
/// from the media server itself (i.e. arrived after our <c>/media/{provider}/...</c> 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)
|
|
/// </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 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;
|
|
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);
|
|
}
|
|
}
|