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
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>
468 lines
18 KiB
C#
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
|
|
};
|
|
}
|