Merge pull request 'fix(480): probe external-JSON remote-stream URLs before ffmpeg' (#486) from fix/480-external-json-probe into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m49s
Renovate / Renovate (push) Failing after 14m57s
Build CI Toolchain Image / Build & push CI image (push) Successful in 1m20s
Dependency vulnerability scan / NuGet vulnerable packages (push) Successful in 2m15s

This commit was merged in pull request #486.
This commit is contained in:
2026-07-19 22:37:29 +00:00
3 changed files with 275 additions and 3 deletions
@@ -26,6 +26,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexSecretStore _plexSecretStore;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IRemoteStreamProber _remoteStreamProber;
public ExternalJsonPlayoutItemProvider(
IDbContextFactory<TvContext> dbContextFactory,
@@ -34,6 +35,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore,
ILocalStatisticsProvider localStatisticsProvider,
IRemoteStreamProber remoteStreamProber,
ILogger<ExternalJsonPlayoutItemProvider> logger)
{
_dbContextFactory = dbContextFactory;
@@ -42,6 +44,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
_plexServerApiClient = plexServerApiClient;
_plexSecretStore = plexSecretStore;
_localStatisticsProvider = localStatisticsProvider;
_remoteStreamProber = remoteStreamProber;
_logger = logger;
}
@@ -217,15 +220,29 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
foreach (PlexServerAuthToken token in maybeToken)
{
var plexUrl =
$"http://localhost:{Settings.StreamingPort}/media/plex/{server.Id}/{program.PlexFile}";
// #480: probe the remote-stream URL before handing it to ffmpeg, exactly as the
// generated-playout path does in
// GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath (#473). Without
// this, an item that is gone from the media server 404s under ffmpeg (exit 8) and the
// same dead item is re-selected for its whole slot. The fail-open contract (only a
// *redirected* 404 fails closed) lives inside IRemoteStreamProber, so this call site
// only owns the decision to probe, not the policy. Probing first also skips the Plex
// metadata round-trip when the item is already gone.
if (!await _remoteStreamProber.IsAvailable(plexUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(plexUrl);
}
MediaItem mediaItem = program.Type switch
{
"episode" => await GetPlexEpisode(server, connection, token, program),
_ => await GetPlexMovie(server, connection, token, program)
};
return new PlayoutItemWithPath(
GetPlayoutItem(startTime, mediaItem, program),
$"http://localhost:{Settings.StreamingPort}/media/plex/{server.Id}/{program.PlexFile}");
return new PlayoutItemWithPath(GetPlayoutItem(startTime, mediaItem, program), plexUrl);
}
}
}
@@ -0,0 +1,220 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Infrastructure.Streaming;
/// <summary>
/// ersatztv#480: external-JSON playout channels build their own <c>/media/plex/...</c> URL and used
/// to hand it to ffmpeg unprobed, so the #473 class survived there — a media item gone from the
/// server 404s under ffmpeg (exit 8) and the same dead item is re-selected for its whole slot. The
/// remote path must now probe the URL via <see cref="IRemoteStreamProber" /> first, failing closed
/// only on a media-server (redirected) 404. The fail-open contract itself is pinned by
/// <c>HttpRemoteStreamProberTests</c>; these tests pin that the external-JSON path routes through it.
/// </summary>
[TestFixture]
public class ExternalJsonPlayoutItemProviderTests
{
private const string ServerKey = "server1";
private const string ClientIdentifier = "client1";
private const string PlexFile = "shows/example/s01e01.mkv";
private const string ScheduleFile = "/config/externaljson/channel1.json";
private const string LocalPath = "/plex/shows/example/s01e01.mkv";
private readonly DateTimeOffset _now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
private InMemoryTvContext _db = null!;
private IFileSystem _fileSystem = null!;
private ILocalStatisticsProvider _localStatisticsProvider = null!;
private IPlexPathReplacementService _plexPathReplacementService = null!;
private IPlexSecretStore _plexSecretStore = null!;
private IPlexServerApiClient _plexServerApiClient = null!;
private IRemoteStreamProber _remoteStreamProber = null!;
private DomainChannel _channel = null!;
private int _serverId;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_fileSystem = Substitute.For<IFileSystem>();
_localStatisticsProvider = Substitute.For<ILocalStatisticsProvider>();
_plexPathReplacementService = Substitute.For<IPlexPathReplacementService>();
_plexSecretStore = Substitute.For<IPlexSecretStore>();
_plexServerApiClient = Substitute.For<IPlexServerApiClient>();
_remoteStreamProber = Substitute.For<IRemoteStreamProber>();
await SeedAsync();
// the JSON schedule exists, but the resolved local file does not — so the provider takes the
// remote-stream branch, which is the one #480 fixes
_fileSystem.File.Exists(ScheduleFile).Returns(true);
_fileSystem.File.Exists(LocalPath).Returns(false);
_fileSystem.File.ReadAllTextAsync(ScheduleFile, Arg.Any<CancellationToken>()).Returns(ScheduleJson());
_plexPathReplacementService
.GetReplacementPlexPath(Arg.Any<int>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(LocalPath);
_plexSecretStore.GetServerAuthToken(Arg.Any<string>())
.Returns(Option<PlexServerAuthToken>.Some(new PlexServerAuthToken(ClientIdentifier, "token")));
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private string ExpectedUrl => $"http://localhost:{Settings.StreamingPort}/media/plex/{_serverId}/{PlexFile}";
[Test]
public async Task Should_Return_Not_Available_Error_When_Remote_Stream_Is_Unavailable()
{
_remoteStreamProber.IsAvailable(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
Either<BaseError, PlayoutItemWithPath> result =
await CreateProvider().CheckForExternalJson(_channel, _now, "/ffprobe", CancellationToken.None);
// fails closed with the error the handler renders as a real "not available" card (not exit 8)
result.IsLeft.ShouldBeTrue();
result.LeftToSeq().Head.ShouldBeOfType<PlayoutItemNotAvailableFromMediaServer>();
await _remoteStreamProber.Received(1).IsAvailable(ExpectedUrl, Arg.Any<CancellationToken>());
// probing first means a gone item never pays for the plex metadata round-trip
await _plexServerApiClient.DidNotReceive().GetEpisodeMetadataAndStatistics(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<PlexConnection>(),
Arg.Any<PlexServerAuthToken>());
}
[Test]
public async Task Should_Return_Playout_Item_With_Url_When_Remote_Stream_Is_Available()
{
_remoteStreamProber.IsAvailable(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);
_plexServerApiClient.GetEpisodeMetadataAndStatistics(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<PlexConnection>(),
Arg.Any<PlexServerAuthToken>())
.Returns(
Right<BaseError, Tuple<EpisodeMetadata, MediaVersion>>(
Tuple(new EpisodeMetadata(), new MediaVersion { Name = "Main", Streams = [] })));
Either<BaseError, PlayoutItemWithPath> result =
await CreateProvider().CheckForExternalJson(_channel, _now, "/ffprobe", CancellationToken.None);
// fail-open: an available remote stream still yields the playable /media/plex URL
result.IsRight.ShouldBeTrue();
result.RightToSeq().Head.Path.ShouldBe(ExpectedUrl);
await _remoteStreamProber.Received(1).IsAvailable(ExpectedUrl, Arg.Any<CancellationToken>());
}
private ExternalJsonPlayoutItemProvider CreateProvider() =>
new(
_db.Factory,
_fileSystem,
_plexPathReplacementService,
_plexServerApiClient,
_plexSecretStore,
_localStatisticsProvider,
_remoteStreamProber,
Microsoft.Extensions.Logging.Abstractions.NullLogger<ExternalJsonPlayoutItemProvider>.Instance);
private static string ScheduleJson() =>
JsonConvert.SerializeObject(
new ExternalJsonChannel
{
// one program, a 1h window starting 1 minute before "now", so it is the current item
StartTime = "2026-01-01T11:59:00Z",
Programs =
[
new ExternalJsonProgram
{
Type = "episode",
Duration = 3_600_000,
PlexFile = PlexFile,
File = "/plexserver/shows/example/s01e01.mkv",
ServerKey = ServerKey,
RatingKey = "12345",
Title = "Example",
ShowTitle = "Example Show",
Season = 1,
Episode = 1
}
]
});
private async Task SeedAsync()
{
await using TvContext context = _db.CreateContext();
var source = new PlexMediaSource
{
ServerName = ServerKey,
ClientIdentifier = ClientIdentifier,
ProductVersion = "1",
Platform = "Linux",
PlatformVersion = "1",
PathReplacements = [],
Connections = [new PlexConnection { IsActive = true, Uri = "http://plex:32400" }],
Libraries =
[
new PlexLibrary
{
Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies,
Key = "1",
ShouldSyncItems = true,
Paths = [new LibraryPath { Path = "/plex" }]
}
]
};
await context.PlexMediaSources.AddAsync(source);
await context.SaveChangesAsync();
_serverId = source.Id;
_channel = new DomainChannel(Guid.NewGuid())
{
Number = "1",
Name = "External JSON",
Group = "ErsatzTV",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStream,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
};
context.Channels.Add(_channel);
await context.SaveChangesAsync();
context.Playouts.Add(
new Playout
{
ChannelId = _channel.Id,
ScheduleKind = PlayoutScheduleKind.ExternalJson,
ScheduleFile = ScheduleFile
});
await context.SaveChangesAsync();
}
}
+35
View File
@@ -2186,3 +2186,38 @@ protection by accident of control flow, not by design.
(`MediaServer{Television,Movie,OtherVideo}LibraryScannerTests`) prove the wiring: empty incoming +
non-empty existing flags nothing and reindexes nothing. Proven non-vacuous by neutralizing the guard and
watching all four anti-nuke assertions fail while the `(0,0)` no-op case stays green.
## 2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)
The #473 fix (PR #479, the entry above dated 2026-07-19) probed Plex/Jellyfin/Emby remote-stream URLs in
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, but explicitly scoped itself to the
**generated-playout** path and flagged `ExternalJsonPlayoutItemProvider` as the surviving hole (tracked as
#480). That provider builds its own `/media/plex/{server}/{plexFile}` URL in `StreamRemotely` and its result
is assigned in the handler *without* passing through `ValidatePlayoutItemPath`, so for external-JSON channels
a media item gone from the server still 404'd under ffmpeg (exit 8) and the same dead item was re-selected
for its whole slot. This closes that gap.
- **Fix location: the provider, not a new handler choke point.** `StreamRemotely` now probes the URL via the
same `IRemoteStreamProber` seam before returning it, mirroring the three generated-playout branches. The
handler-side single-choke-point option (route *every* `PlayoutItemWithPath` through one validator) was
rejected: the external-JSON provider constructs a **synthetic** `MediaItem`/`PlayoutItem` (from the JSON +
Plex API) whose path is already a `/media/plex/...` URL, so it does not fit `ValidatePlayoutItemPath`'s
local-path-then-per-provider-switch derivation without contortion. Per-provider probing keeps the blast
radius to one method; the fail-open **policy** (only a *redirected* 404 fails closed) lives entirely inside
`IRemoteStreamProber`, so the second call site duplicates only the *decision to probe*, not the policy.
- **Probe before the Plex metadata round-trip.** The URL depends only on `server.Id` + `program.PlexFile`,
not on the constructed `MediaItem`, so the probe runs *before* `GetPlexEpisode`/`GetPlexMovie`. A gone item
therefore also skips the Plex API metadata call (which would otherwise `throw NotSupportedException` on a
`Left`).
- **Error type + card sizing.** An unavailable stream returns `PlayoutItemNotAvailableFromMediaServer`, which
the handler already maps (case added in #473) to a real-error card rather than "Channel is Offline". One
behavioural difference from the generated path is accepted: external-JSON channels have **no DB
`PlayoutItem` rows** (their schedule is a JSON file), so the handler's `maybeNextStart` query returns none
and the error card falls to the existing 1-minute work-ahead clamp instead of spanning to the next item.
The worker then re-tunes and advances by time. That is repeated 1-minute error cards within a long dead
slot rather than one long card — strictly better than the exit-8 loop, and sizing to the next JSON program
would require parsing the schedule here (deferred, out of scope).
- **Tests.** `ExternalJsonPlayoutItemProviderTests` pins both directions (unavailable → `Left`
`PlayoutItemNotAvailableFromMediaServer` and no metadata call; available → `Right` with the `/media/plex`
URL). The fail-open contract itself stays pinned by `HttpRemoteStreamProberTests`. Proven non-vacuous by
neutralizing the probe guard and watching the unavailable assertion flip to `Right`.