Files
ersatztv/ErsatzTV.Tests/Application/Channels/GetChannelPlaybackSourceHandlerTests.cs
T
timothyandOpenAI Codex 97397906e3
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 31s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
docs(jellyfin): record player-owned playback verdict
Record the isolated Jellyfin 12 live evidence and the server-plugin-only no-go. Remove the discovery snapshot invalidated by Jellyfin's actual playback call path, scope the guide cache to connection settings, and fail closed for dangling mirror sources.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 20:15:35 +02:00

468 lines
18 KiB
C#

using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelPlaybackSourceHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Resolve_Jellyfin_Item_And_Schedule_Offset()
{
string jellyfinItemId = Guid.NewGuid().ToString("N");
DateTime start = Now.UtcDateTime.AddMinutes(-10);
DateTime finish = Now.UtcDateTime.AddMinutes(20);
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(7, "7.1");
var movie = new JellyfinMovie
{
Id = 70,
ItemId = jellyfinItemId,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var playout = new Playout { Id = 71, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 72,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
InPoint = TimeSpan.FromMinutes(5),
OutPoint = TimeSpan.FromMinutes(35),
FillerKind = FillerKind.None
};
context.Channels.Add(channel);
context.JellyfinMovies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(7, Now);
result.ChannelId.ShouldBe(7);
result.SourceChannelId.ShouldBe(7);
result.SourceAt.ShouldBe(Now);
result.NextTransitionAt.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
result.Active.ShouldNotBeNull();
result.Active.Start.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(15).Ticks);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
result.Active.Source.ItemId.ShouldBe(jellyfinItemId);
result.Active.Source.Path.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Use_Actual_Filler_Item_Not_Guide_Display_Item()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(8, "8");
var filler = new OtherVideo
{
Id = 80,
OtherVideoMetadata = [],
MediaVersions =
[
new MediaVersion
{
MediaFiles = [new MediaFile { Path = "/media/bumper.mkv", PathHash = "bumper" }]
}
]
};
var playout = new Playout { Id = 81, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 82,
MediaItem = filler,
MediaItemId = filler.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1),
OutPoint = TimeSpan.FromMinutes(2),
FillerKind = FillerKind.MidRoll,
GuideGroup = 4
};
context.Channels.Add(channel);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(8, Now);
result.Active.ShouldNotBeNull();
result.Active.PlayoutItemId.ShouldBe(82);
result.Active.FillerKind.ShouldBe(FillerKind.MidRoll);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.LocalFile);
result.Active.Source.Path.ShouldBe("/media/bumper.mkv");
}
[Test]
public async Task Handle_Should_Apply_Mirror_Clock_And_Viewer_Facing_Timestamps()
{
TimeSpan offset = TimeSpan.FromHours(1);
DateTime sourceStart = Now.UtcDateTime.Subtract(offset).AddMinutes(-5);
DateTime sourceFinish = Now.UtcDateTime.Subtract(offset).AddMinutes(25);
await using (TvContext context = _db.CreateContext())
{
Channel source = MakeChannel(9, "9");
Channel mirror = MakeChannel(10, "10");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = source.Id;
mirror.PlayoutOffset = offset;
var remote = new RemoteStream
{
Id = 90,
Url = "https://example.invalid/live.m3u8",
IsLive = true,
RemoteStreamMetadata = [],
MediaVersions = []
};
var playout = new Playout { Id = 91, Channel = source, ChannelId = source.Id, Items = [] };
var item = new PlayoutItem
{
Id = 92,
MediaItem = remote,
MediaItemId = remote.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = sourceStart,
Finish = sourceFinish,
OutPoint = TimeSpan.FromMinutes(30)
};
context.Channels.AddRange(source, mirror);
context.RemoteStreams.Add(remote);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(10, Now);
result.SourceChannelId.ShouldBe(9);
result.SourceAt.ShouldBe(Now.Subtract(offset));
result.Active.ShouldNotBeNull();
result.Active.Start.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
result.Active.Finish.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
result.NextTransitionAt.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(5).Ticks);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.RemoteUrl);
result.Active.Source.IsLive.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Select_Item_At_Start_And_Exclude_Item_At_Finish()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(11, "11");
var first = new Movie { Id = 110, MovieMetadata = [], MediaVersions = [] };
var second = new Movie { Id = 111, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 112, Channel = channel, ChannelId = channel.Id, Items = [] };
context.Channels.Add(channel);
context.Movies.AddRange(first, second);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
new PlayoutItem
{
Id = 113,
MediaItem = first,
MediaItemId = first.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-30),
Finish = Now.UtcDateTime
},
new PlayoutItem
{
Id = 114,
MediaItem = second,
MediaItemId = second.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime,
Finish = Now.UtcDateTime.AddMinutes(30)
});
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(11, Now);
result.Active.ShouldNotBeNull();
result.Active.PlayoutItemId.ShouldBe(114);
result.Active.MediaItemId.ShouldBe(111);
result.Active.Start.ShouldBe(Now);
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.Zero.Ticks);
result.NextTransitionAt.ShouldBe(Now.AddMinutes(30));
}
[Test]
public async Task Handle_Should_Return_Next_Start_During_Gap()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(12, "12");
var movie = new Movie { Id = 120, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 121, Channel = channel, ChannelId = channel.Id, Items = [] };
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
new PlayoutItem
{
Id = 122,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-30),
Finish = Now.UtcDateTime
},
new PlayoutItem
{
Id = 123,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(10),
Finish = Now.UtcDateTime.AddMinutes(40)
});
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(12, Now);
result.Active.ShouldBeNull();
result.NextTransitionAt.ShouldBe(Now.AddMinutes(10));
}
[Test]
public async Task Handle_Should_Return_No_Next_Transition_During_Terminal_Gap()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(13, "13");
var movie = new Movie { Id = 130, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
var finishedItem = new PlayoutItem
{
Id = 132,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddHours(-1),
Finish = Now.UtcDateTime.AddMinutes(-30)
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(finishedItem);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(13, Now);
result.Active.ShouldBeNull();
result.NextTransitionAt.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Map_Server_Items_And_Unsupported_Empty_Local_Media()
{
const string jellyfinItemId = "jellyfin-episode";
const string plexKey = "/library/metadata/123";
const string embyItemId = "emby-movie";
await using (TvContext context = _db.CreateContext())
{
var jellyfinEpisode = new JellyfinEpisode
{
Id = 140,
ItemId = jellyfinItemId,
Etag = string.Empty,
EpisodeMetadata = [],
MediaVersions = []
};
var plexMovie = new PlexMovie
{
Id = 150,
Key = plexKey,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var embyMovie = new EmbyMovie
{
Id = 160,
ItemId = embyItemId,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var emptyLocalMovie = new Movie { Id = 170, MovieMetadata = [], MediaVersions = [] };
AddActiveItem(context, MakeChannel(14, "14"), jellyfinEpisode, 141, 142);
AddActiveItem(context, MakeChannel(15, "15"), plexMovie, 151, 152);
AddActiveItem(context, MakeChannel(16, "16"), embyMovie, 161, 162);
AddActiveItem(context, MakeChannel(17, "17"), emptyLocalMovie, 171, 172);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceReferenceResponseModel jellyfin = (await GetResult(14, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel plex = (await GetResult(15, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel emby = (await GetResult(16, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel unsupported = (await GetResult(17, Now)).Active!.Source;
jellyfin.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
jellyfin.ItemId.ShouldBe(jellyfinItemId);
plex.Kind.ShouldBe(ChannelPlaybackSourceKind.PlexItem);
plex.ItemId.ShouldBe(plexKey);
emby.Kind.ShouldBe(ChannelPlaybackSourceKind.EmbyItem);
emby.ItemId.ShouldBe(embyItemId);
unsupported.Kind.ShouldBe(ChannelPlaybackSourceKind.Unsupported);
unsupported.ItemId.ShouldBeNull();
unsupported.Path.ShouldBeNull();
unsupported.IsLive.ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Return_None_For_Missing_Channel()
{
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(404, Now),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Return_None_For_Mirror_Without_Source_Channel()
{
await using (TvContext context = _db.CreateContext())
{
Channel mirror = MakeChannel(18, "18");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
var staleMovie = new Movie { Id = 180, MovieMetadata = [], MediaVersions = [] };
var stalePlayout = new Playout
{
Id = 181,
Channel = mirror,
ChannelId = mirror.Id,
Items = []
};
var staleItem = new PlayoutItem
{
Id = 182,
MediaItem = staleMovie,
MediaItemId = staleMovie.Id,
Playout = stalePlayout,
PlayoutId = stalePlayout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1)
};
context.Channels.Add(mirror);
context.Movies.Add(staleMovie);
context.Playouts.Add(stalePlayout);
context.PlayoutItems.Add(staleItem);
await context.SaveChangesAsync();
}
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(18, Now),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
private async Task<ChannelPlaybackSourceResponseModel> GetResult(int channelId, DateTimeOffset at)
{
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(channelId, at),
CancellationToken.None);
return result.Match(
Some: value => value,
None: () => throw new AssertionException("Expected a playback-source response"));
}
private static void AddActiveItem(
TvContext context,
Channel channel,
MediaItem mediaItem,
int playoutId,
int playoutItemId)
{
var playout = new Playout { Id = playoutId, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = playoutItemId,
MediaItem = mediaItem,
MediaItemId = mediaItem.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1)
};
context.Channels.Add(channel);
context.MediaItems.Add(mediaItem);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
}
private static Channel MakeChannel(int id, string number) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = $"Channel {number}",
Group = "Test",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated
};
}