Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97397906e3 | ||
|
|
63f72c7c50 | ||
|
|
dd4ce6f378 | ||
|
|
724ca5168f |
@@ -119,6 +119,7 @@ public class GetChannelGuideDataHandler(
|
||||
|
||||
responseChannels.Add(
|
||||
new ChannelGuideChannelResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
programmes.OrderBy(p => p.Start).ToList()));
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaybackSource(int ChannelId, DateTimeOffset At)
|
||||
: IRequest<Option<ChannelPlaybackSourceResponseModel>>;
|
||||
@@ -0,0 +1,171 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the physical playout item at a point in time without invoking the streaming or FFmpeg pipeline.
|
||||
/// Guide projection is deliberately not used here: guide entries may merge filler or split a block differently
|
||||
/// from the actual media-item boundaries a player must follow.
|
||||
/// </summary>
|
||||
public class GetChannelPlaybackSourceHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelPlaybackSource, Option<ChannelPlaybackSourceResponseModel>>
|
||||
{
|
||||
public async Task<Option<ChannelPlaybackSourceResponseModel>> Handle(
|
||||
GetChannelPlaybackSource request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Channel? channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == request.ChannelId, cancellationToken);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Deleting a mirror's source sets this nullable FK to null. Do not self-resolve to a stale
|
||||
// playout that may remain attached to the mirror channel.
|
||||
if (channel.PlayoutSource == ChannelPlayoutSource.Mirror && channel.MirrorSourceChannelId is null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
int sourceChannelId = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
? channel.MirrorSourceChannelId!.Value
|
||||
: channel.Id;
|
||||
TimeSpan playoutOffset = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
? channel.PlayoutOffset ?? TimeSpan.Zero
|
||||
: TimeSpan.Zero;
|
||||
DateTime sourceAtUtc = request.At.UtcDateTime - playoutOffset;
|
||||
|
||||
PlayoutItem? active = await ActiveItems(dbContext, sourceChannelId, sourceAtUtc)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
DateTime? nextSourceTransition = active?.Finish;
|
||||
if (nextSourceTransition is null)
|
||||
{
|
||||
nextSourceTransition = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.Playout.ChannelId == sourceChannelId && pi.Start > sourceAtUtc)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Select(pi => (DateTime?)pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
DateTimeOffset resolvedAt = request.At.ToUniversalTime();
|
||||
DateTimeOffset sourceAt = new(sourceAtUtc, TimeSpan.Zero);
|
||||
DateTimeOffset? nextTransitionAt = nextSourceTransition.HasValue
|
||||
? new DateTimeOffset(nextSourceTransition.Value + playoutOffset, TimeSpan.Zero)
|
||||
: null;
|
||||
|
||||
ChannelPlaybackItemResponseModel? playbackItem = active is null
|
||||
? null
|
||||
: ToPlaybackItem(active, sourceAtUtc, playoutOffset);
|
||||
|
||||
return new ChannelPlaybackSourceResponseModel(
|
||||
channel.Id,
|
||||
sourceChannelId,
|
||||
resolvedAt,
|
||||
sourceAt,
|
||||
nextTransitionAt,
|
||||
playbackItem);
|
||||
}
|
||||
|
||||
private static IQueryable<PlayoutItem> ActiveItems(TvContext dbContext, int sourceChannelId, DateTime sourceAtUtc) =>
|
||||
dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.Playout.ChannelId == sourceChannelId)
|
||||
.Where(pi => pi.Start <= sourceAtUtc && pi.Finish > sourceAtUtc)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.AsSplitQuery();
|
||||
|
||||
private static ChannelPlaybackItemResponseModel ToPlaybackItem(
|
||||
PlayoutItem item,
|
||||
DateTime sourceAtUtc,
|
||||
TimeSpan playoutOffset)
|
||||
{
|
||||
TimeSpan currentOffset = item.InPoint + (sourceAtUtc - item.Start);
|
||||
if (currentOffset < item.InPoint)
|
||||
{
|
||||
currentOffset = item.InPoint;
|
||||
}
|
||||
|
||||
if (item.OutPoint > item.InPoint && currentOffset > item.OutPoint)
|
||||
{
|
||||
currentOffset = item.OutPoint;
|
||||
}
|
||||
|
||||
return new ChannelPlaybackItemResponseModel(
|
||||
item.Id,
|
||||
item.MediaItemId,
|
||||
new DateTimeOffset(item.Start + playoutOffset, TimeSpan.Zero),
|
||||
new DateTimeOffset(item.Finish + playoutOffset, TimeSpan.Zero),
|
||||
item.InPoint.Ticks,
|
||||
currentOffset.Ticks,
|
||||
item.OutPoint.Ticks,
|
||||
item.FillerKind,
|
||||
GetSourceReference(item.MediaItem));
|
||||
}
|
||||
|
||||
private static ChannelPlaybackSourceReferenceResponseModel GetSourceReference(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
JellyfinMovie movie => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: movie.ItemId),
|
||||
JellyfinEpisode episode => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: episode.ItemId),
|
||||
PlexMovie movie => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: movie.Key),
|
||||
PlexEpisode episode => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: episode.Key),
|
||||
PlexOtherVideo video => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: video.Key),
|
||||
EmbyMovie movie => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: movie.ItemId),
|
||||
EmbyEpisode episode => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: episode.ItemId),
|
||||
RemoteStream stream => Reference(ChannelPlaybackSourceKind.RemoteUrl, isLive: stream.IsLive),
|
||||
Movie movie => LocalFile(movie.MediaVersions),
|
||||
Episode episode => LocalFile(episode.MediaVersions),
|
||||
MusicVideo video => LocalFile(video.MediaVersions),
|
||||
OtherVideo video => LocalFile(video.MediaVersions),
|
||||
Song song => LocalFile(song.MediaVersions),
|
||||
Image image => LocalFile(image.MediaVersions),
|
||||
_ => Reference(ChannelPlaybackSourceKind.Unsupported)
|
||||
};
|
||||
|
||||
private static ChannelPlaybackSourceReferenceResponseModel LocalFile(IEnumerable<MediaVersion> versions)
|
||||
{
|
||||
string? path = versions.FirstOrDefault()?.MediaFiles.FirstOrDefault()?.Path;
|
||||
return string.IsNullOrWhiteSpace(path)
|
||||
? Reference(ChannelPlaybackSourceKind.Unsupported)
|
||||
: Reference(ChannelPlaybackSourceKind.LocalFile, path: path);
|
||||
}
|
||||
|
||||
private static ChannelPlaybackSourceReferenceResponseModel Reference(
|
||||
ChannelPlaybackSourceKind kind,
|
||||
string? itemId = null,
|
||||
string? path = null,
|
||||
bool isLive = false) =>
|
||||
new(kind, itemId, path, isLive);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public record ChannelGuideProgrammeResponseModel(
|
||||
|
||||
/// <summary>One channel's guide programmes for the requested window.</summary>
|
||||
public record ChannelGuideChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
string Name,
|
||||
List<ChannelGuideProgrammeResponseModel> Programmes);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
/// <summary>The kind of source selected by the channel schedule.</summary>
|
||||
public enum ChannelPlaybackSourceKind
|
||||
{
|
||||
LocalFile,
|
||||
JellyfinItem,
|
||||
PlexItem,
|
||||
EmbyItem,
|
||||
RemoteUrl,
|
||||
Unsupported
|
||||
}
|
||||
|
||||
/// <summary>A token-free reference to the scheduled media source.</summary>
|
||||
public record ChannelPlaybackSourceReferenceResponseModel(
|
||||
ChannelPlaybackSourceKind Kind,
|
||||
string? ItemId,
|
||||
string? Path,
|
||||
bool IsLive);
|
||||
|
||||
/// <summary>The physical playout item covering the requested channel time.</summary>
|
||||
public record ChannelPlaybackItemResponseModel(
|
||||
int PlayoutItemId,
|
||||
int MediaItemId,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
long InPointTicks,
|
||||
long CurrentOffsetTicks,
|
||||
long OutPointTicks,
|
||||
FillerKind FillerKind,
|
||||
ChannelPlaybackSourceReferenceResponseModel Source);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a viewer-facing channel time to the physical media item selected by the schedule.
|
||||
/// This is intentionally playback-engine neutral and never starts an ErsatzTV transcoder.
|
||||
/// </summary>
|
||||
public record ChannelPlaybackSourceResponseModel(
|
||||
int ChannelId,
|
||||
int SourceChannelId,
|
||||
DateTimeOffset ResolvedAt,
|
||||
DateTimeOffset SourceAt,
|
||||
DateTimeOffset? NextTransitionAt,
|
||||
ChannelPlaybackItemResponseModel? Active);
|
||||
@@ -61,6 +61,7 @@ public class GetChannelGuideDataHandlerTests
|
||||
CancellationToken.None);
|
||||
|
||||
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
|
||||
result.Channels.Single().Id.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
using MediatR;
|
||||
@@ -273,6 +274,28 @@ public class ChannelControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackSource_Should_Require_Authentication_And_Map_Query()
|
||||
{
|
||||
DateTimeOffset at = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
|
||||
var model = new ChannelPlaybackSourceResponseModel(7, 7, at, at, null, null);
|
||||
_mediator.Send(Arg.Any<GetChannelPlaybackSource>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelPlaybackSourceResponseModel>.Some(model));
|
||||
|
||||
IActionResult result = await _controller.GetPlaybackSource(7, at, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelPlaybackSource>(q => q.ChannelId == 7 && q.At == at),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
MethodInfo method = typeof(ChannelController).GetMethod(nameof(ChannelController.GetPlaybackSource))!;
|
||||
method.GetCustomAttribute<RequiresAuthenticationAttribute>().ShouldNotBeNull();
|
||||
ResponseCacheAttribute cache = method.GetCustomAttribute<ResponseCacheAttribute>()!;
|
||||
cache.NoStore.ShouldBeTrue();
|
||||
cache.Location.ShouldBe(ResponseCacheLocation.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Serialization;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.OpenApi;
|
||||
@@ -71,6 +72,32 @@ public class OpenApiContractHonestyTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Serialized_Gated_Operations_Should_Name_The_ApiKey_Scheme()
|
||||
{
|
||||
string json = await _document.SerializeAsJsonAsync(
|
||||
OpenApiSpecVersion.OpenApi3_1,
|
||||
CancellationToken.None);
|
||||
using JsonDocument serialized = JsonDocument.Parse(json);
|
||||
|
||||
foreach (JsonProperty path in serialized.RootElement.GetProperty("paths").EnumerateObject())
|
||||
{
|
||||
foreach (JsonProperty operation in path.Value.EnumerateObject())
|
||||
{
|
||||
if (operation.NameEquals("parameters"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement security = operation.Value.GetProperty("security");
|
||||
security.GetArrayLength().ShouldBeGreaterThan(0, $"{operation.Name} {path.Name} should declare security");
|
||||
security[0].TryGetProperty(ApiSecurityOperationTransformer.SchemeName, out JsonElement scopes)
|
||||
.ShouldBeTrue($"{operation.Name} {path.Name} should name the ApiKey scheme after serialization");
|
||||
scopes.ValueKind.ShouldBe(JsonValueKind.Array);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Troubleshoot_Playback_Actions_Should_Carry_Stable_Explicit_OperationIds()
|
||||
{
|
||||
|
||||
@@ -32,6 +32,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integrations", "integrations", "{4958D7D8-4791-2CCE-6FFA-082B65933577}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "jellyfin", "jellyfin", "{65793B68-0114-8A23-3D53-9EDEAEBFFD0F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV\Jellyfin.Plugin.ChicoryTV.csproj", "{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV.Tests", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV.Tests\Jellyfin.Plugin.ChicoryTV.Tests.csproj", "{10235034-68F6-44AC-8C54-4C6F12DAD534}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -315,6 +323,42 @@ Global
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.Build.0 = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.Build.0 = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -325,5 +369,8 @@ Global
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{65793B68-0114-8A23-3D53-9EDEAEBFFD0F} = {4958D7D8-4791-2CCE-6FFA-082B65933577}
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -11,6 +11,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -48,6 +49,28 @@ public class ChannelController(
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/v1/channels/{id:int}/playback-source")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Resolve the scheduled playback source for a channel")]
|
||||
[EndpointDescription(
|
||||
"Returns the physical media item and in-file offset selected by the channel schedule at the requested " +
|
||||
"time. This read-only endpoint never starts an ErsatzTV transcoder. at defaults to now.")]
|
||||
[EndpointGroupName("general")]
|
||||
[RequiresAuthentication]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
[ProducesResponseType(typeof(ChannelPlaybackSourceResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPlaybackSource(
|
||||
int id,
|
||||
[FromQuery] DateTimeOffset? at,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ChannelPlaybackSourceResponseModel> result = await mediator.Send(
|
||||
new GetChannelPlaybackSource(id, at ?? DateTimeOffset.UtcNow),
|
||||
cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available music video credits template names")]
|
||||
|
||||
@@ -36,7 +36,11 @@ public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvid
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference(SchemeName)] = new List<string>()
|
||||
// Microsoft.OpenApi 2.x needs the host document to serialize this as the
|
||||
// component name. Without it the in-memory requirement looks populated,
|
||||
// but the generated JSON contains `security: [ {} ]`, which means anonymous
|
||||
// access in OpenAPI rather than the X-Api-Key requirement enforced at runtime.
|
||||
[new OpenApiSecuritySchemeReference(SchemeName, context.Document, null)] = new List<string>()
|
||||
});
|
||||
|
||||
operation.Responses ??= new OpenApiResponses();
|
||||
|
||||
+963
-242
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,8 @@ Also present in `docs/`:
|
||||
Largely superseded day-to-day by `docs/api-conventions.md`; read this for the original rationale.
|
||||
- **`docs/channels.md`** — Channel entity field reference.
|
||||
- **`docs/m3u-xmltv.md`** — M3U/XMLTV generation overview (`ChannelPlaylist`, `GetChannelGuideHandler`).
|
||||
- **`docs/player-owned-playback-poc.md`** — issue #357 feasibility record for a thin Jellyfin Live TV
|
||||
plugin, native player/transcode ownership, and the deliberately deferred Kodi decision.
|
||||
- **`docs/fork-strategy.md`** — divergence policy vs upstream ErsatzTV.
|
||||
- **`docs/design-sync.md`** — Claude Design ↔ repo screen workflow (#92).
|
||||
- **`docs/endpoint-index.md`** — generated REST endpoint index (method/path/operationId/summary per
|
||||
|
||||
@@ -1809,3 +1809,26 @@ guards to these pre-existing promise completions; changing those semantics belon
|
||||
Detailed behavior tests now render `PlayoutsScreen` directly with a scoped fetch mock, including the
|
||||
zero-playout Add Playout affordance, lock/409 handling, refresh/poll ownership, action and kind gates, and
|
||||
dialog flows. Refs #245 #243.
|
||||
|
||||
## 2026-07-15 — Player-owned channels: retain the continuous stream after Jellyfin PoC (#357)
|
||||
|
||||
The phase-one Jellyfin 12 live probe validates only the narrow adapter seam: ChicoryTV can remain
|
||||
authoritative for channels/schedules, a thin `ILiveTvService` can project its guide and select the scheduled
|
||||
Jellyfin library item, and Jellyfin can own direct-play/transcode negotiation. A forced HLS request launched
|
||||
Jellyfin's native FFmpeg against that item while ErsatzTV launched none. Playback reporting stayed on the
|
||||
virtual `LiveTvChannel`, so the backing episode did not enter Continue Watching; no history-suppression toggle
|
||||
is warranted.
|
||||
|
||||
It does **not** validate a continuous-channel replacement. `ILiveTvService` receives neither a schedule-offset
|
||||
argument nor a viewer/session identity during source resolution. Stock Jellyfin web tunes at position zero.
|
||||
Jellyfin can seek natively when a client supplies the position (confirmed by an offset HLS segment producing
|
||||
the matching FFmpeg `-ss`), but the provider cannot connect that mechanism to the EPG clock. At a live boundary,
|
||||
a fresh tune selected the next scheduled item while the pre-boundary media-source ID became unusable; Jellyfin
|
||||
did not retune the existing channel.
|
||||
|
||||
**Decision:** keep ChicoryTV's existing continuous M3U/XMLTV stream as the supported playback path. The
|
||||
default-off plugin and additive scheduled-source read are feasibility seams only, isolated-lab-only because
|
||||
provider lookup also lacks per-user backing-library authorization. Do not start Kodi-specific implementation
|
||||
until an upstream Jellyfin offset/session/transition contract or a deliberately thin client companion first
|
||||
proves the missing semantics without moving stream orchestration back into ChicoryTV. Detailed evidence:
|
||||
`docs/player-owned-playback-poc.md`. Refs #357.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
160 endpoints, 242 operations.
|
||||
161 endpoints, 243 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
| DELETE | `/api/v1/channels/{id}` | ChannelDelete | Delete a channel |
|
||||
| GET | `/api/v1/channels/{id}` | GetChannelById | Get a channel by id |
|
||||
| PUT | `/api/v1/channels/{id}` | ChannelUpdate | Update a channel |
|
||||
| GET | `/api/v1/channels/{id}/playback-source` | ChannelGetPlaybackSource | Resolve the scheduled playback source for a channel |
|
||||
| POST | `/api/v1/channels/{id}/playout/reset` | ChannelResetPlayout | Reset channel playout |
|
||||
| GET | `/api/v1/guide` | ChannelGetGuide | Get the JSON channel guide (EPG) |
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Player-owned channel playback PoC (#357)
|
||||
|
||||
Status: phase-one Jellyfin live probe completed 2026-07-15. A server-plugin-only replacement is a
|
||||
no-go; Kodi work remains deliberately deferred and issue #357 stays open unless its completion scope is
|
||||
formally narrowed to the feasibility result.
|
||||
|
||||
## Current verdict
|
||||
|
||||
A thin Jellyfin Live TV plugin can reuse ChicoryTV channels and guide data, select a backing Jellyfin
|
||||
library item, and leave direct-play/direct-stream/transcode negotiation to Jellyfin. That part fits the
|
||||
desired ownership model.
|
||||
|
||||
Stock Jellyfin 12.0-rc2 does **not**, however, give an `ILiveTvService` provider a way to supply the
|
||||
schedule-derived position in that file. Jellyfin seeks from the client's `StartTimeTicks`; its web client
|
||||
tunes a Live TV channel with zero, and the provider's stream methods receive no start-time argument.
|
||||
Jellyfin also marks provider sources as infinite Live TV. The directly usable native-file path retains the
|
||||
backing source's finite runtime metadata, but Jellyfin still does not treat that runtime or the EPG boundary as
|
||||
a request to reopen the channel on the next scheduled item.
|
||||
|
||||
The result is therefore:
|
||||
|
||||
- **Go** only for the metadata/source-selection proof and Jellyfin-owned codec negotiation on one selected
|
||||
item.
|
||||
- **No-go** for replacing the existing continuous channel path with a server-plugin-only implementation
|
||||
until both provider-supplied initial offset and boundary transition semantics have a proven solution. The
|
||||
installed probe demonstrated both failures live.
|
||||
- **Isolated-lab-only for the probe.** The Live TV provider contract has no user context when it resolves the
|
||||
backing item, so the plugin cannot enforce per-library user restrictions. An "administrator-only" warning is
|
||||
not an authorization control; the live probe must use a loopback-only cloned Jellyfin with no real users or
|
||||
external access.
|
||||
- **Do not start Kodi-specific implementation yet.** Preserve the existing M3U/XMLTV path and use the
|
||||
Jellyfin result to decide whether Kodi can reuse an official Jellyfin integration or needs a thin companion.
|
||||
|
||||
## Intended ecosystem and ownership
|
||||
|
||||
| Concern | Owner in the proposed split |
|
||||
|---|---|
|
||||
| Channels, schedules, playout items, filler decisions | ChicoryTV |
|
||||
| Advanced configuration, wizards, API and MCP automation | ChicoryTV |
|
||||
| Jellyfin-side configuration | Only ChicoryTV URL and machine API key |
|
||||
| Channel and EPG presentation in Jellyfin | Native Jellyfin Live TV through a thin plugin |
|
||||
| File lookup inside Jellyfin | Jellyfin library item ID and native media-source lookup |
|
||||
| Direct play, direct stream, fallback transcode | Jellyfin and the requesting client |
|
||||
| Kodi during this phase | Existing IPTV/PVR M3U + XMLTV path, unchanged |
|
||||
|
||||
ChicoryTV remains the only authoritative state. The plugin is an adapter, not a second channel editor or
|
||||
schedule store.
|
||||
|
||||
## Phase-one seam
|
||||
|
||||
The adapter reuses the existing JSON guide and adds one authenticated read:
|
||||
|
||||
```text
|
||||
GET /api/v1/guide?start=...&end=...
|
||||
GET /api/v1/channels/{id}/playback-source?at=...
|
||||
```
|
||||
|
||||
Guide channels now include their stable ChicoryTV channel ID. `playback-source` resolves that viewer-facing
|
||||
channel time to the actual physical `PlayoutItem`, including filler, rather than to the guide display item.
|
||||
It returns:
|
||||
|
||||
- source channel and mirror-adjusted lookup time;
|
||||
- physical playout item, start/finish, in/out points, and current in-file offset;
|
||||
- the next physical transition;
|
||||
- a typed source reference (`JellyfinItem`, `LocalFile`, `PlexItem`, `EmbyItem`, or `RemoteUrl`). Credential-bearing
|
||||
remote URLs are deliberately not returned by this phase-one endpoint.
|
||||
|
||||
The handler depends only on the database context. It never asks the FFmpeg process or segmenter services to
|
||||
start work. The endpoint is explicitly machine-key authenticated and non-cacheable because local paths may be
|
||||
present for local-library items. The first plugin probe accepts only `JellyfinItem`, so it cannot silently fall
|
||||
back to an ErsatzTV proxy or transcoder.
|
||||
|
||||
Jellyfin asks a provider for programme data per channel. The probe holds one 30-second guide snapshot that
|
||||
covers the requested window so a refresh does not rebuild the full ChicoryTV guide once for every channel.
|
||||
The snapshot is scoped to the current normalized base URL and API key, so a configuration change cannot reuse
|
||||
guide data from the previous upstream connection.
|
||||
|
||||
Mirrors use the source clock (`requested time - PlayoutOffset`) and return viewer-facing transition times.
|
||||
Selection is start-inclusive and finish-exclusive. Continuous generated channels are the phase-one target;
|
||||
on-demand clock shifting and `ExternalJson` guide channels are not yet supported by this seam.
|
||||
|
||||
## Exact Jellyfin limitation
|
||||
|
||||
The checked runtime is Jellyfin Server and Web `v12.0-rc2` on .NET 10.
|
||||
|
||||
1. `ILiveTvService.GetChannelStreamMediaSources` and `GetChannelStream` can return a native file-backed
|
||||
`MediaSourceInfo`, but neither method receives the requested schedule offset.
|
||||
2. Jellyfin's playback-info and open-live-stream routes take `StartTimeTicks` from the client request.
|
||||
3. The stock web client maps a currently airing programme to its channel and starts it with position zero.
|
||||
4. Jellyfin can seek natively when the client supplies a position: progressive playback can carry
|
||||
`StartTimeTicks`, while HLS selects the segment at the desired runtime and Jellyfin launches FFmpeg with
|
||||
`-ss`. The provider cannot populate either client-side choice through the Live TV contract.
|
||||
5. Jellyfin forces Live TV media sources to `IsInfiniteStream = true`. For the probe's supported non-opening
|
||||
native sources, `RunTimeTicks` remains present, but neither it nor guide refresh becomes a transition scheduler
|
||||
for an already-open stream.
|
||||
|
||||
Returning the file alone therefore proves native playback ownership but starts at the beginning. Returning a
|
||||
custom continuous HTTP/HLS stream could solve position and transitions, but it would make the plugin or
|
||||
ChicoryTV a stream orchestrator again—the complexity this direction is meant to remove.
|
||||
|
||||
The smallest architecture-compatible paths to investigate after the live probe are either an upstream
|
||||
Jellyfin provider contract that carries an initial offset and transition/reopen semantics, or a deliberately
|
||||
thin client companion that supplies `StartTimeTicks` and retunes at a boundary. Both must be evaluated before
|
||||
custom streaming code.
|
||||
|
||||
## Continue Watching and history
|
||||
|
||||
No first-phase history toggle is required. Native Live TV playback keeps the session `ItemId` as the virtual
|
||||
`LiveTvChannel`; the backing episode/movie is only media-source data. Jellyfin writes progress against the
|
||||
session item, and `LiveTvChannel` explicitly does not support position resume. Consequently, briefly surfing
|
||||
past a scheduled episode should not add that episode to Continue Watching or change its played state.
|
||||
|
||||
The live probe confirmed this with a fresh clone-only user and a synthetic five-minute start/progress/stop
|
||||
sequence. The active session reported the virtual channel ID; Resume contained zero items before and after;
|
||||
the backing episode remained unplayed with position zero. A suppression toggle would therefore duplicate
|
||||
behavior Jellyfin already provides. The plugin must continue to leave the backing item out of the session
|
||||
`ItemId`.
|
||||
|
||||
## Reuse matrix
|
||||
|
||||
| Existing component | Reuse | Notes |
|
||||
|---|---:|---|
|
||||
| Channel model and editing | Unchanged | Plugin reads projected channel IDs/names/numbers. |
|
||||
| Playout schedules and physical item timing | Unchanged | New read projects existing `PlayoutItem` state. |
|
||||
| JSON guide / shared guide projector | Unchanged + additive ID | Same guide grouping/filler metadata as XMLTV. |
|
||||
| M3U/XMLTV and Kodi IPTV Simple behavior | Unchanged | Remains the rollback and current Kodi path. |
|
||||
| REST API/auth | Reused | One additive, explicitly authenticated read endpoint. |
|
||||
| MCP and automation | Unchanged | ChicoryTV remains authoritative; no plugin-side writes. |
|
||||
| Channel wizards / simple programming flow | Unchanged | They continue producing the same channel/schedule state. |
|
||||
| ChicoryTV advanced web UI | Unchanged | No duplicate editor in Jellyfin. |
|
||||
| ErsatzTV FFmpeg/segmenter | Not used by the probe | Still available per-client/per-channel as fallback. |
|
||||
| Watermarks, graphics and subtitle burn-in | Not reproduced | Native-file playback cannot preserve rendered overlays. |
|
||||
| On-demand playout clock | Deferred | Needs an explicit player-owned clock contract. |
|
||||
| External JSON playout guide | Deferred | Existing JSON guide intentionally omits it. |
|
||||
| Per-user backing-library authorization | Blocking gap | `ILiveTvService` source resolution has no user argument. |
|
||||
| Multiple Jellyfin servers | Probe constraint | Item IDs are resolved on the Jellyfin server hosting the plugin; the phase-one contract does not yet encode server identity. |
|
||||
|
||||
## Live evidence (2026-07-15)
|
||||
|
||||
The probe ran in disposable sibling containers, bound only to host loopback: a fresh-key ErsatzTV clone on
|
||||
port 8411 and a Jellyfin clone on port 8097. Both databases came from online SQLite snapshots; the Jellyfin
|
||||
clone loaded only ChicoryTV plus built-in plugins and mounted the real media read-only. Production containers,
|
||||
configuration, histories, and databases were not modified. The tested server was Jellyfin `12.0.0`
|
||||
(`jellyfin/jellyfin:12.0-rc2`); the ErsatzTV image was built from `63f72c7c`.
|
||||
|
||||
### Positive results
|
||||
|
||||
- Jellyfin started in 4.14 seconds and loaded ChicoryTV 0.1.0. Its native `RefreshGuide` task completed in
|
||||
79.4 seconds, imported all 43 channels, and exposed both sampled channels (100 and 101) with ten EPG rows in
|
||||
the sampled two-hour window.
|
||||
- Warm `PlaybackInfo` requests for two channels took 13–23 ms. On every sampled tune, Jellyfin's returned
|
||||
native media-source ID exactly matched the `JellyfinItem` ID selected by the schedule endpoint.
|
||||
- A forced HLS profile returned a 565,880-byte segment while exactly one Jellyfin FFmpeg process ran against
|
||||
the selected library file. ErsatzTV ran zero FFmpeg processes. Jellyfin chose its normal hardware/native
|
||||
codec path; the plugin supplied no encoder command.
|
||||
- Native seeking itself works. At a schedule offset of 1,110.644 seconds, selecting the HLS segment at
|
||||
1,111.110 seconds caused Jellyfin FFmpeg to use `-ss 00:18:31.110`; ErsatzTV still ran no FFmpeg process.
|
||||
- Playback reporting kept the virtual `LiveTvChannel` as the active item. After a five-minute synthetic watch,
|
||||
the fresh user's Resume count remained zero and the backing episode remained unplayed at position zero.
|
||||
|
||||
### Blocking results
|
||||
|
||||
- The provider logged a schedule offset of 805.644 seconds for a direct source. Requests with zero and with
|
||||
that offset both returned HTTP 200 and the same first 1 MiB as the beginning of the backing file, with no
|
||||
FFmpeg process. Stock Jellyfin web supplies a start position of zero for a channel tune, and the provider has
|
||||
no way to replace it. The native seek capability therefore exists but is not connected to the EPG clock.
|
||||
- A default forced-transcode tune returned a valid segment through Jellyfin, but its FFmpeg command had no
|
||||
`-ss` and opened the backing file at the beginning. The offset HLS proof above required the client to select
|
||||
the offset segment explicitly.
|
||||
- At the channel-102 boundary, a pre-boundary tune selected one backing item and a fresh post-boundary tune
|
||||
correctly selected the next. Reusing the pre-boundary media-source ID after the boundary returned HTTP 400;
|
||||
Jellyfin's streaming path had no media source for it. `ILiveTvService` supplies no viewer/session identity
|
||||
with which the plugin could preserve an old viewer's source while returning the new source to a fresh tune,
|
||||
and Jellyfin performed no retune.
|
||||
|
||||
### One-sample measurements
|
||||
|
||||
| State | ErsatzTV clone | Jellyfin clone | Observed latency |
|
||||
|---|---:|---:|---:|
|
||||
| Idle after guide import | 0.40% CPU / 436.5 MiB | 0.00% CPU / 618.5 MiB | warm `PlaybackInfo`: 13–23 ms |
|
||||
| One offset HLS segment | 0.35% CPU / 436.7 MiB | 97.73% CPU / 900.7 MiB | first segment: 2.56 s |
|
||||
|
||||
These are single lab samples, not capacity benchmarks. The transcode sample used Jellyfin's hardware path and
|
||||
ended with zero FFmpeg processes in both containers.
|
||||
|
||||
### Gates deliberately stopped
|
||||
|
||||
A second boundary, finite-file EOF, long-running A/V stability, and visual quality comparison were not run.
|
||||
The required architecture had already failed both initial position and its first clean boundary, so those
|
||||
tests could not reverse the server-plugin-only verdict. They remain open if issue #357 retains its original
|
||||
full end-to-end completion gate. Kodi implementation was not started.
|
||||
|
||||
The live result chooses the no-go branch for the current contract: retain the existing M3U/XMLTV continuous
|
||||
stream. A future experiment must first prove either an upstream Jellyfin provider seam for offset/session-aware
|
||||
transitions or a deliberately thin client companion. The plugin remains default-off and isolated-lab-only
|
||||
because its independent per-user backing-library authorization gap also remains unresolved.
|
||||
|
||||
## Rollback
|
||||
|
||||
The experiment is isolated and makes no schema change. Disable/remove the Jellyfin plugin and clients continue
|
||||
using the existing M3U/XMLTV stream URLs. The additive API read has no producer side effects and can remain for
|
||||
future adapters or be removed before release if the direction is rejected.
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Jellyfin.Plugin.ChicoryTV.Configuration;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class ErsatzTvClientTests
|
||||
{
|
||||
[Test]
|
||||
public async Task GuideCacheIsScopedToCurrentConnectionSettings()
|
||||
{
|
||||
var configuration = new PluginConfiguration
|
||||
{
|
||||
BaseUrl = "http://first.invalid",
|
||||
ApiKey = "key-a"
|
||||
};
|
||||
IPluginConfigurationAccessor accessor = Substitute.For<IPluginConfigurationAccessor>();
|
||||
accessor.Configuration.Returns(configuration);
|
||||
var handler = new RecordingHandler();
|
||||
using var httpClient = new HttpClient(handler);
|
||||
using var client = new ErsatzTvClient(accessor, TimeProvider.System, httpClient);
|
||||
|
||||
await client.GetGuideAsync(null, null, CancellationToken.None);
|
||||
await client.GetGuideAsync(null, null, CancellationToken.None);
|
||||
configuration.ApiKey = "key-b";
|
||||
await client.GetGuideAsync(null, null, CancellationToken.None);
|
||||
configuration.BaseUrl = "http://second.invalid";
|
||||
await client.GetGuideAsync(null, null, CancellationToken.None);
|
||||
|
||||
handler.Requests.Count.ShouldBe(3);
|
||||
handler.Requests[0].ShouldBe(new RecordedRequest(new Uri("http://first.invalid/api/v1/guide"), "key-a"));
|
||||
handler.Requests[1].ShouldBe(new RecordedRequest(new Uri("http://first.invalid/api/v1/guide"), "key-b"));
|
||||
handler.Requests[2].ShouldBe(new RecordedRequest(new Uri("http://second.invalid/api/v1/guide"), "key-b"));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values);
|
||||
Requests.Add(new RecordedRequest(request.RequestUri!, values?.SingleOrDefault()));
|
||||
|
||||
const string json =
|
||||
"{\"start\":\"2026-07-14T00:00:00Z\",\"end\":\"2026-07-16T00:00:00Z\",\"channels\":[]}";
|
||||
return Task.FromResult(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedRequest(Uri Uri, string? ApiKey);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Jellyfin.Plugin.ChicoryTV.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class ErsatzTvLiveTvServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task OpenReResolvesSourceAtCurrentScheduleTime()
|
||||
{
|
||||
DateTimeOffset now = new(2026, 7, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
var fixture = new ServiceFixture(now);
|
||||
Guid itemAId = Guid.NewGuid();
|
||||
Guid itemBId = Guid.NewGuid();
|
||||
var sourceA = new MediaSourceInfo { Id = "source-a" };
|
||||
var sourceB = new MediaSourceInfo { Id = "source-b" };
|
||||
fixture.AddItem(itemAId, sourceA);
|
||||
fixture.AddItem(itemBId, sourceB);
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult(PlaybackResponse(now, itemAId)),
|
||||
Task.FromResult(PlaybackResponse(now.AddSeconds(1), itemBId)));
|
||||
|
||||
List<MediaSourceInfo> discovered = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
fixture.Time.Advance(TimeSpan.FromSeconds(2));
|
||||
MediaSourceInfo opened = await fixture.Service.GetChannelStream("1", "source-b", default);
|
||||
|
||||
discovered.ShouldBe([sourceA]);
|
||||
opened.ShouldBeSameAs(sourceB);
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
1,
|
||||
now,
|
||||
Arg.Any<CancellationToken>());
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
1,
|
||||
now.AddSeconds(2),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EachDiscoveryResolvesCurrentScheduledItem()
|
||||
{
|
||||
DateTimeOffset now = new(2026, 7, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
var fixture = new ServiceFixture(now);
|
||||
Guid itemAId = Guid.NewGuid();
|
||||
Guid itemBId = Guid.NewGuid();
|
||||
var sourceA = new MediaSourceInfo { Id = "source-a" };
|
||||
var sourceB = new MediaSourceInfo { Id = "source-b" };
|
||||
fixture.AddItem(itemAId, sourceA);
|
||||
fixture.AddItem(itemBId, sourceB);
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult(PlaybackResponse(now, itemAId)),
|
||||
Task.FromResult(PlaybackResponse(now.AddSeconds(16), itemBId)));
|
||||
|
||||
List<MediaSourceInfo> first = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
fixture.Time.Advance(TimeSpan.FromSeconds(16));
|
||||
List<MediaSourceInfo> second = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
|
||||
first.ShouldBe([sourceA]);
|
||||
second.ShouldBe([sourceB]);
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
1,
|
||||
now.AddSeconds(16),
|
||||
Arg.Any<CancellationToken>());
|
||||
await fixture.Client.Received(2).GetPlaybackSourceAsync(
|
||||
1,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DiscoveryFiltersRequiresOpeningAndOpenSelectsRequestedStreamId()
|
||||
{
|
||||
DateTimeOffset now = new(2026, 7, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
var fixture = new ServiceFixture(now);
|
||||
Guid itemId = Guid.NewGuid();
|
||||
var nested = new MediaSourceInfo { Id = "nested", RequiresOpening = true };
|
||||
var first = new MediaSourceInfo { Id = "first" };
|
||||
var requested = new MediaSourceInfo { Id = "requested" };
|
||||
fixture.AddItem(itemId, nested, first, requested);
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PlaybackResponse(now, itemId));
|
||||
|
||||
List<MediaSourceInfo> discovered = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
MediaSourceInfo opened = await fixture.Service.GetChannelStream("1", "REQUESTED", default);
|
||||
|
||||
discovered.ShouldBe([first, requested]);
|
||||
opened.ShouldBeSameAs(requested);
|
||||
await fixture.Client.Received(2).GetPlaybackSourceAsync(
|
||||
1,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OpenWithBlankStreamIdUsesFirstDiscoveredSource()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
Guid itemId = Guid.NewGuid();
|
||||
var first = new MediaSourceInfo { Id = "first" };
|
||||
var second = new MediaSourceInfo { Id = "second" };
|
||||
fixture.AddItem(itemId, first, second);
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PlaybackResponse(fixture.Time.GetUtcNow(), itemId));
|
||||
|
||||
await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
MediaSourceInfo opened = await fixture.Service.GetChannelStream("1", string.Empty, default);
|
||||
|
||||
opened.ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiscoveryRejectsWhenEveryNativeSourceRequiresOpening()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
Guid itemId = Guid.NewGuid();
|
||||
fixture.AddItem(itemId, new MediaSourceInfo { Id = "nested", RequiresOpening = true });
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PlaybackResponse(fixture.Time.GetUtcNow(), itemId));
|
||||
|
||||
NotSupportedException exception = Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("1", default))!;
|
||||
|
||||
exception.Message.ShouldContain("no native media source usable");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiscoveryRejectsMalformedChannelIdWithoutCallingErsatzTv()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
|
||||
InvalidOperationException exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("not-an-integer", default))!;
|
||||
|
||||
exception.Message.ShouldContain("is not an integer");
|
||||
fixture.Client.DidNotReceiveWithAnyArgs().GetPlaybackSourceAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiscoveryRejectsChannelWithNoActiveItem()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PlaybackSourceResponse
|
||||
{
|
||||
ChannelId = 1,
|
||||
ResolvedAt = fixture.Time.GetUtcNow()
|
||||
});
|
||||
|
||||
InvalidOperationException exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("1", default))!;
|
||||
|
||||
exception.Message.ShouldContain("has no active playout item");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiscoveryRejectsNonJellyfinSource()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PlaybackResponse(fixture.Time.GetUtcNow(), (string?)null, PlaybackSourceKind.LocalFile));
|
||||
|
||||
NotSupportedException exception = Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("1", default))!;
|
||||
|
||||
exception.Message.ShouldContain("only JellyfinItem is supported");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiscoveryRejectsMalformedJellyfinItemId()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PlaybackResponse(fixture.Time.GetUtcNow(), "not-a-guid"));
|
||||
|
||||
InvalidOperationException exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("1", default))!;
|
||||
|
||||
exception.Message.ShouldContain("invalid Jellyfin item id");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DiscoveryRejectsMissingJellyfinItem()
|
||||
{
|
||||
var fixture = new ServiceFixture();
|
||||
Guid missingItemId = Guid.NewGuid();
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PlaybackResponse(fixture.Time.GetUtcNow(), missingItemId));
|
||||
|
||||
InvalidOperationException exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("1", default))!;
|
||||
|
||||
exception.Message.ShouldContain("was not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UnsafeLabAcknowledgementIsOffByDefaultAndBlocksExposure()
|
||||
{
|
||||
var fixture = new ServiceFixture(unsafeLabAccessEnabled: false);
|
||||
|
||||
IEnumerable<ChannelInfo> channels = await fixture.Service.GetChannelsAsync(default);
|
||||
InvalidOperationException exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => fixture.Service.GetChannelStreamMediaSources("1", default))!;
|
||||
|
||||
channels.ShouldBeEmpty();
|
||||
exception.Message.ShouldContain("Explicitly acknowledge the unsafe lab setting");
|
||||
await fixture.Client.DidNotReceiveWithAnyArgs().GetGuideAsync(default, default, default);
|
||||
await fixture.Client.DidNotReceiveWithAnyArgs().GetPlaybackSourceAsync(default, default, default);
|
||||
new PluginConfiguration().EnableUnsafeLabAccess.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static PlaybackSourceResponse PlaybackResponse(
|
||||
DateTimeOffset resolvedAt,
|
||||
Guid? itemId,
|
||||
PlaybackSourceKind kind = PlaybackSourceKind.JellyfinItem) =>
|
||||
PlaybackResponse(resolvedAt, itemId?.ToString(), kind);
|
||||
|
||||
private static PlaybackSourceResponse PlaybackResponse(
|
||||
DateTimeOffset resolvedAt,
|
||||
string? itemId,
|
||||
PlaybackSourceKind kind = PlaybackSourceKind.JellyfinItem) =>
|
||||
new()
|
||||
{
|
||||
ChannelId = 1,
|
||||
ResolvedAt = resolvedAt,
|
||||
Active = new PlaybackItem
|
||||
{
|
||||
CurrentOffsetTicks = TimeSpan.FromMinutes(3).Ticks,
|
||||
Source = new PlaybackSourceReference
|
||||
{
|
||||
Kind = kind,
|
||||
ItemId = itemId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private sealed class ServiceFixture
|
||||
{
|
||||
public ServiceFixture(
|
||||
DateTimeOffset? now = null,
|
||||
bool unsafeLabAccessEnabled = true)
|
||||
{
|
||||
Time = new ManualTimeProvider(
|
||||
now ?? new DateTimeOffset(2026, 7, 14, 12, 0, 0, TimeSpan.Zero));
|
||||
Client = Substitute.For<IErsatzTvClient>();
|
||||
ConfigurationAccessor = Substitute.For<IPluginConfigurationAccessor>();
|
||||
LibraryManager = Substitute.For<ILibraryManager>();
|
||||
MediaSourceManager = Substitute.For<IMediaSourceManager>();
|
||||
ConfigurationAccessor.Configuration.Returns(new PluginConfiguration
|
||||
{
|
||||
EnableUnsafeLabAccess = unsafeLabAccessEnabled
|
||||
});
|
||||
Service = new ErsatzTvLiveTvService(
|
||||
Client,
|
||||
ConfigurationAccessor,
|
||||
LibraryManager,
|
||||
MediaSourceManager,
|
||||
NullLogger<ErsatzTvLiveTvService>.Instance,
|
||||
Time);
|
||||
}
|
||||
|
||||
public IErsatzTvClient Client { get; }
|
||||
|
||||
public IPluginConfigurationAccessor ConfigurationAccessor { get; }
|
||||
|
||||
public ILibraryManager LibraryManager { get; }
|
||||
|
||||
public IMediaSourceManager MediaSourceManager { get; }
|
||||
|
||||
public ManualTimeProvider Time { get; }
|
||||
|
||||
public ErsatzTvLiveTvService Service { get; }
|
||||
|
||||
public Movie AddItem(Guid itemId, params MediaSourceInfo[] sources)
|
||||
{
|
||||
var item = new Movie { Id = itemId };
|
||||
LibraryManager.GetItemById(itemId).Returns(item);
|
||||
MediaSourceManager.GetPlaybackMediaSources(
|
||||
item,
|
||||
null!,
|
||||
true,
|
||||
true,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<MediaSourceInfo>>(sources));
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan amount) => _utcNow = _utcNow.Add(amount);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Jellyfin.Plugin.ChicoryTV/Jellyfin.Plugin.ChicoryTV.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The plugin excludes Jellyfin runtime assets because the server supplies them.
|
||||
Tests run outside Jellyfin, so they need the pinned assemblies in the test host. -->
|
||||
<PackageReference Include="Jellyfin.Controller" VersionOverride="12.0.0-rc2" />
|
||||
<PackageReference Include="Jellyfin.Model" VersionOverride="12.0.0-rc2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV.Configuration;
|
||||
|
||||
/// <summary>Configuration for the ErsatzTV connection.</summary>
|
||||
public sealed class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="PluginConfiguration"/> class.</summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
BaseUrl = "http://localhost:8409";
|
||||
ApiKey = string.Empty;
|
||||
EnableUnsafeLabAccess = false;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the ErsatzTV server base URL.</summary>
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the ErsatzTV API key.</summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the authorization-incomplete lab probe is enabled.
|
||||
/// </summary>
|
||||
public bool EnableUnsafeLabAccess { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>ChicoryTV</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ChicoryTvConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="ChicoryTvConfigForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="BaseUrl">ErsatzTV base URL</label>
|
||||
<input id="BaseUrl" name="BaseUrl" type="url" is="emby-input" required />
|
||||
<div class="fieldDescription">For example, http://localhost:8409</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ApiKey">API key</label>
|
||||
<input id="ApiKey" name="ApiKey" type="password" is="emby-input" autocomplete="off" />
|
||||
<div class="fieldDescription">Sent to ErsatzTV as X-Api-Key.</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableUnsafeLabAccess" name="EnableUnsafeLabAccess" type="checkbox" is="emby-checkbox" />
|
||||
<span>I understand this unsafe lab probe bypasses per-user backing-library authorization</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Required to expose any ChicoryTV channels or sources. This is not administrator-role
|
||||
enforcement. Do not enable it on a shared or untrusted Jellyfin deployment.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var ChicoryTvConfig = {
|
||||
pluginUniqueId: '3e2c64db-7f24-4bf4-b388-d4aa27f93aa8'
|
||||
};
|
||||
|
||||
document.querySelector('#ChicoryTvConfigPage')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(ChicoryTvConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#BaseUrl').value = config.BaseUrl || '';
|
||||
document.querySelector('#ApiKey').value = config.ApiKey || '';
|
||||
document.querySelector('#EnableUnsafeLabAccess').checked = config.EnableUnsafeLabAccess === true;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#ChicoryTvConfigForm')
|
||||
.addEventListener('submit', function (event) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(ChicoryTvConfig.pluginUniqueId).then(function (config) {
|
||||
config.BaseUrl = document.querySelector('#BaseUrl').value.trim();
|
||||
config.ApiKey = document.querySelector('#ApiKey').value.trim();
|
||||
config.EnableUnsafeLabAccess = document.querySelector('#EnableUnsafeLabAccess').checked;
|
||||
ApiClient.updatePluginConfiguration(ChicoryTvConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _guideLock = new(1, 1);
|
||||
private readonly IPluginConfigurationAccessor _configurationAccessor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private GuideResponse? _cachedGuide;
|
||||
private DateTimeOffset _cachedGuideExpiresAt;
|
||||
private ConnectionSettings? _cachedGuideConnection;
|
||||
|
||||
public ErsatzTvClient(
|
||||
IPluginConfigurationAccessor configurationAccessor,
|
||||
TimeProvider timeProvider)
|
||||
: this(configurationAccessor, timeProvider, new HttpClient())
|
||||
{
|
||||
}
|
||||
|
||||
internal ErsatzTvClient(
|
||||
IPluginConfigurationAccessor configurationAccessor,
|
||||
TimeProvider timeProvider,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_configurationAccessor = configurationAccessor;
|
||||
_timeProvider = timeProvider;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public async Task<GuideResponse> GetGuideAsync(
|
||||
DateTimeOffset? start,
|
||||
DateTimeOffset? end,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _guideLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ConnectionSettings connection = GetConnectionSettings();
|
||||
GuideResponse? cached = GetCachedGuide(start, end, connection);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
GuideResponse guide = await GetGuideCoreAsync(start, end, connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_cachedGuide = guide;
|
||||
_cachedGuideExpiresAt = _timeProvider.GetUtcNow().AddSeconds(30);
|
||||
_cachedGuideConnection = connection;
|
||||
return guide;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_guideLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PlaybackSourceResponse> GetPlaybackSourceAsync(
|
||||
int channelId,
|
||||
DateTimeOffset at,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string timestamp = Uri.EscapeDataString(at.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
return GetAsync<PlaybackSourceResponse>(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"api/v1/channels/{channelId}/playback-source?at={timestamp}"),
|
||||
GetConnectionSettings(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_guideLock.Dispose();
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private GuideResponse? GetCachedGuide(
|
||||
DateTimeOffset? start,
|
||||
DateTimeOffset? end,
|
||||
ConnectionSettings connection)
|
||||
{
|
||||
GuideResponse? cached = _cachedGuide;
|
||||
if (cached is null
|
||||
|| _cachedGuideConnection != connection
|
||||
|| _timeProvider.GetUtcNow() >= _cachedGuideExpiresAt)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (!start.HasValue || cached.Start <= start.Value)
|
||||
&& (!end.HasValue || cached.End >= end.Value)
|
||||
? cached
|
||||
: null;
|
||||
}
|
||||
|
||||
private Task<GuideResponse> GetGuideCoreAsync(
|
||||
DateTimeOffset? start,
|
||||
DateTimeOffset? end,
|
||||
ConnectionSettings connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var relativePath = "api/v1/guide";
|
||||
if (start.HasValue && end.HasValue)
|
||||
{
|
||||
relativePath += string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"?start={Uri.EscapeDataString(start.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture))}" +
|
||||
$"&end={Uri.EscapeDataString(end.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
|
||||
return GetAsync<GuideResponse>(relativePath, connection, cancellationToken);
|
||||
}
|
||||
|
||||
private ConnectionSettings GetConnectionSettings()
|
||||
{
|
||||
var configuration = _configurationAccessor.Configuration
|
||||
?? throw new InvalidOperationException("The ChicoryTV plugin has not been initialized.");
|
||||
string configuredBaseUrl = configuration.BaseUrl?.Trim() ?? string.Empty;
|
||||
if (!Uri.TryCreate(configuredBaseUrl.TrimEnd('/') + "/", UriKind.Absolute, out Uri? baseUri)
|
||||
|| (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException("Configure an absolute HTTP or HTTPS ErsatzTV base URL.");
|
||||
}
|
||||
|
||||
return new ConnectionSettings(baseUri, configuration.ApiKey ?? string.Empty);
|
||||
}
|
||||
|
||||
private async Task<T> GetAsync<T>(
|
||||
string relativePath,
|
||||
ConnectionSettings connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(connection.BaseUri, relativePath));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (!string.IsNullOrWhiteSpace(connection.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Api-Key", connection.ApiKey);
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string detail = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (detail.Length > 512)
|
||||
{
|
||||
detail = detail[..512];
|
||||
}
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"ErsatzTV GET {request.RequestUri?.AbsolutePath} failed with {(int)response.StatusCode} " +
|
||||
$"({response.ReasonPhrase}): {detail}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<T>(body, JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new JsonException($"ErsatzTV returned an empty {typeof(T).Name} response.");
|
||||
}
|
||||
|
||||
private sealed record ConnectionSettings(Uri BaseUri, string ApiKey);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PlaybackSourceKind>))]
|
||||
public enum PlaybackSourceKind
|
||||
{
|
||||
LocalFile,
|
||||
JellyfinItem,
|
||||
PlexItem,
|
||||
EmbyItem,
|
||||
RemoteUrl,
|
||||
Unsupported
|
||||
}
|
||||
|
||||
public sealed class GuideResponse
|
||||
{
|
||||
public DateTimeOffset Start { get; set; }
|
||||
|
||||
public DateTimeOffset End { get; set; }
|
||||
|
||||
public List<GuideChannel> Channels { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class GuideChannel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Number { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public List<GuideProgramme> Programmes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class GuideProgramme
|
||||
{
|
||||
public DateTimeOffset Start { get; set; }
|
||||
|
||||
public DateTimeOffset Stop { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string? SubTitle { get; set; }
|
||||
|
||||
public string? Category { get; set; }
|
||||
|
||||
public JsonElement FillerKind { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlaybackSourceResponse
|
||||
{
|
||||
public int ChannelId { get; set; }
|
||||
|
||||
public int SourceChannelId { get; set; }
|
||||
|
||||
public DateTimeOffset ResolvedAt { get; set; }
|
||||
|
||||
public DateTimeOffset SourceAt { get; set; }
|
||||
|
||||
public DateTimeOffset? NextTransitionAt { get; set; }
|
||||
|
||||
public PlaybackItem? Active { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlaybackItem
|
||||
{
|
||||
public int PlayoutItemId { get; set; }
|
||||
|
||||
public int MediaItemId { get; set; }
|
||||
|
||||
public DateTimeOffset Start { get; set; }
|
||||
|
||||
public DateTimeOffset Finish { get; set; }
|
||||
|
||||
public long InPointTicks { get; set; }
|
||||
|
||||
public long CurrentOffsetTicks { get; set; }
|
||||
|
||||
public long OutPointTicks { get; set; }
|
||||
|
||||
public JsonElement FillerKind { get; set; }
|
||||
|
||||
public PlaybackSourceReference Source { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class PlaybackSourceReference
|
||||
{
|
||||
public PlaybackSourceKind Kind { get; set; }
|
||||
|
||||
public string? ItemId { get; set; }
|
||||
|
||||
public string? Path { get; set; }
|
||||
|
||||
public bool IsLive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Globalization;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.LiveTv;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
{
|
||||
private const string ReadOnlyMessage = "ChicoryTV is a read-only Live TV probe and does not support timers.";
|
||||
private const string UnsafeLabAccessMessage =
|
||||
"ChicoryTV is disabled. Explicitly acknowledge the unsafe lab setting before exposing channels. " +
|
||||
"This probe cannot enforce per-user access to backing Jellyfin library items.";
|
||||
private readonly IErsatzTvClient _client;
|
||||
private readonly IPluginConfigurationAccessor _configurationAccessor;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly ILogger<ErsatzTvLiveTvService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private int _disabledWarningLogged;
|
||||
|
||||
public ErsatzTvLiveTvService(
|
||||
IErsatzTvClient client,
|
||||
IPluginConfigurationAccessor configurationAccessor,
|
||||
ILibraryManager libraryManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
ILogger<ErsatzTvLiveTvService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_client = client;
|
||||
_configurationAccessor = configurationAccessor;
|
||||
_libraryManager = libraryManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string Name => "ChicoryTV";
|
||||
|
||||
public string HomePageUrl => "https://github.com/ErsatzTV/ErsatzTV";
|
||||
|
||||
public async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsUnsafeLabAccessEnabled())
|
||||
{
|
||||
LogDisabledOnce();
|
||||
return Array.Empty<ChannelInfo>();
|
||||
}
|
||||
|
||||
GuideResponse guide = await _client.GetGuideAsync(null, null, cancellationToken).ConfigureAwait(false);
|
||||
return guide.Channels.Select(channel => new ChannelInfo
|
||||
{
|
||||
Id = channel.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Number = channel.Number,
|
||||
Name = channel.Name,
|
||||
ChannelType = ChannelType.TV
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(
|
||||
string channelId,
|
||||
DateTime startDateUtc,
|
||||
DateTime endDateUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsUnsafeLabAccessEnabled())
|
||||
{
|
||||
LogDisabledOnce();
|
||||
return Array.Empty<ProgramInfo>();
|
||||
}
|
||||
|
||||
if (!int.TryParse(channelId, NumberStyles.None, CultureInfo.InvariantCulture, out int parsedChannelId))
|
||||
{
|
||||
return Array.Empty<ProgramInfo>();
|
||||
}
|
||||
|
||||
DateTimeOffset start = new(DateTime.SpecifyKind(startDateUtc, DateTimeKind.Utc));
|
||||
DateTimeOffset end = new(DateTime.SpecifyKind(endDateUtc, DateTimeKind.Utc));
|
||||
GuideResponse guide = await _client.GetGuideAsync(start, end, cancellationToken).ConfigureAwait(false);
|
||||
GuideChannel? channel = guide.Channels.FirstOrDefault(candidate => candidate.Id == parsedChannelId);
|
||||
if (channel is null)
|
||||
{
|
||||
return Array.Empty<ProgramInfo>();
|
||||
}
|
||||
|
||||
return channel.Programmes
|
||||
.Where(programme => programme.Stop > start && programme.Start < end)
|
||||
.Select(programme => new ProgramInfo
|
||||
{
|
||||
Id = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{channel.Id}:{programme.Start.UtcDateTime.Ticks}"),
|
||||
ChannelId = channelId,
|
||||
Name = programme.Title,
|
||||
EpisodeTitle = programme.SubTitle,
|
||||
StartDate = programme.Start.UtcDateTime,
|
||||
EndDate = programme.Stop.UtcDateTime,
|
||||
Genres = string.IsNullOrWhiteSpace(programme.Category) ? [] : [programme.Category]
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(
|
||||
string channelId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureUnsafeLabAccessEnabled();
|
||||
int parsedChannelId = ParseChannelId(channelId);
|
||||
ResolvedMediaSources resolved = await ResolveMediaSourcesAsync(
|
||||
parsedChannelId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
LogResolution("discovering", resolved);
|
||||
return [.. resolved.Sources];
|
||||
}
|
||||
|
||||
public async Task<MediaSourceInfo> GetChannelStream(
|
||||
string channelId,
|
||||
string streamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureUnsafeLabAccessEnabled();
|
||||
int parsedChannelId = ParseChannelId(channelId);
|
||||
ResolvedMediaSources resolved = await ResolveMediaSourcesAsync(
|
||||
parsedChannelId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MediaSourceInfo? source = string.IsNullOrWhiteSpace(streamId)
|
||||
? resolved.Sources.FirstOrDefault()
|
||||
: resolved.Sources.FirstOrDefault(
|
||||
candidate => string.Equals(candidate.Id, streamId, StringComparison.OrdinalIgnoreCase));
|
||||
if (source is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Jellyfin media source '{streamId}' is no longer available for ErsatzTV channel {channelId}.");
|
||||
}
|
||||
|
||||
LogResolution("opening", resolved);
|
||||
return source;
|
||||
}
|
||||
|
||||
public Task CloseLiveStream(string id, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task ResetTuner(string id, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IEnumerable<TimerInfo>>(Array.Empty<TimerInfo>());
|
||||
|
||||
public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IEnumerable<SeriesTimerInfo>>(Array.Empty<SeriesTimerInfo>());
|
||||
|
||||
public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(
|
||||
CancellationToken cancellationToken,
|
||||
ProgramInfo? program = null) => NotSupported<SeriesTimerInfo>();
|
||||
|
||||
public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) => NotSupported();
|
||||
|
||||
public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) => NotSupported();
|
||||
|
||||
public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) => NotSupported();
|
||||
|
||||
public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) => NotSupported();
|
||||
|
||||
public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) => NotSupported();
|
||||
|
||||
public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) => NotSupported();
|
||||
|
||||
private static Task NotSupported() => Task.FromException(new NotSupportedException(ReadOnlyMessage));
|
||||
|
||||
private static Task<T> NotSupported<T>() => Task.FromException<T>(new NotSupportedException(ReadOnlyMessage));
|
||||
|
||||
private static int ParseChannelId(string channelId)
|
||||
{
|
||||
if (!int.TryParse(channelId, NumberStyles.None, CultureInfo.InvariantCulture, out int parsedChannelId))
|
||||
{
|
||||
throw new InvalidOperationException($"ErsatzTV channel id '{channelId}' is not an integer.");
|
||||
}
|
||||
|
||||
return parsedChannelId;
|
||||
}
|
||||
|
||||
private async Task<ResolvedMediaSources> ResolveMediaSourcesAsync(
|
||||
int channelId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTimeOffset resolvedAt = _timeProvider.GetUtcNow();
|
||||
PlaybackSourceResponse response = await _client.GetPlaybackSourceAsync(
|
||||
channelId,
|
||||
resolvedAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
PlaybackItem active = response.Active
|
||||
?? throw new InvalidOperationException(
|
||||
$"ErsatzTV channel {channelId} has no active playout item at {response.ResolvedAt:O}.");
|
||||
if (active.Source.Kind != PlaybackSourceKind.JellyfinItem)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"ErsatzTV source kind '{active.Source.Kind}' is not supported by this probe; only JellyfinItem is supported.");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(active.Source.ItemId, out Guid jellyfinItemId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"ErsatzTV returned invalid Jellyfin item id '{active.Source.ItemId}' for channel {channelId}.");
|
||||
}
|
||||
|
||||
List<MediaSourceInfo> sources = await GetNativeMediaSourcesAsync(
|
||||
channelId,
|
||||
active.Source.ItemId,
|
||||
jellyfinItemId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ResolvedMediaSources(
|
||||
response.ChannelId,
|
||||
active.Source.ItemId,
|
||||
active.CurrentOffsetTicks,
|
||||
sources);
|
||||
}
|
||||
|
||||
private async Task<List<MediaSourceInfo>> GetNativeMediaSourcesAsync(
|
||||
int channelId,
|
||||
string jellyfinItemId,
|
||||
Guid jellyfinItemGuid,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(jellyfinItemGuid)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Jellyfin library item {jellyfinItemId} referenced by ErsatzTV channel {channelId} was not found.");
|
||||
IReadOnlyList<MediaSourceInfo> nativeSources = await _mediaSourceManager.GetPlaybackMediaSources(
|
||||
item,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
List<MediaSourceInfo> sources = nativeSources.Where(source => !source.RequiresOpening).ToList();
|
||||
if (sources.Count != nativeSources.Count)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ChicoryTV omitted {Count} native media source(s) that require nested dynamic opening; this first probe supports directly usable Jellyfin media sources only.",
|
||||
nativeSources.Count - sources.Count);
|
||||
}
|
||||
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Jellyfin item {jellyfinItemId} has no native media source usable without dynamic opening.");
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
private bool IsUnsafeLabAccessEnabled() =>
|
||||
_configurationAccessor.Configuration?.EnableUnsafeLabAccess == true;
|
||||
|
||||
private void EnsureUnsafeLabAccessEnabled()
|
||||
{
|
||||
if (!IsUnsafeLabAccessEnabled())
|
||||
{
|
||||
LogDisabledOnce();
|
||||
throw new InvalidOperationException(UnsafeLabAccessMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogDisabledOnce()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disabledWarningLogged, 1) == 0)
|
||||
{
|
||||
_logger.LogWarning("{Message}", UnsafeLabAccessMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogResolution(string operation, ResolvedMediaSources resolved)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ChicoryTV {Operation} ErsatzTV channel {ChannelId} as Jellyfin item {JellyfinItemId}; " +
|
||||
"schedule-derived CurrentOffsetTicks={CurrentOffsetTicks}. " +
|
||||
"ILiveTvService has no playback-offset parameter.",
|
||||
operation,
|
||||
resolved.ChannelId,
|
||||
resolved.JellyfinItemId,
|
||||
resolved.CurrentOffsetTicks);
|
||||
}
|
||||
|
||||
private sealed record ResolvedMediaSources(
|
||||
int ChannelId,
|
||||
string JellyfinItemId,
|
||||
long CurrentOffsetTicks,
|
||||
List<MediaSourceInfo> Sources);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
/// <summary>Reads the guide and schedule-derived playback source from ErsatzTV.</summary>
|
||||
public interface IErsatzTvClient
|
||||
{
|
||||
/// <summary>Gets a guide window.</summary>
|
||||
Task<GuideResponse> GetGuideAsync(
|
||||
DateTimeOffset? start,
|
||||
DateTimeOffset? end,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Gets the source scheduled on a channel at a point in time.</summary>
|
||||
Task<PlaybackSourceResponse> GetPlaybackSourceAsync(
|
||||
int channelId,
|
||||
DateTimeOffset at,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.ChicoryTV</RootNamespace>
|
||||
<AssemblyName>Jellyfin.Plugin.ChicoryTV</AssemblyName>
|
||||
<Version>0.1.0.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0-rc2">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.0.0-rc2">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration/configPage.html" />
|
||||
<EmbeddedResource Include="Configuration/configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>Jellyfin.Plugin.ChicoryTV.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.ChicoryTV.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
/// <summary>The ChicoryTV Jellyfin plugin.</summary>
|
||||
public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="Plugin"/> class.</summary>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "ChicoryTV";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Read-only ErsatzTV guide and Jellyfin-owned Live TV playback probe.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("3e2c64db-7f24-4bf4-b388-d4aa27f93aa8");
|
||||
|
||||
/// <summary>Gets the active plugin instance.</summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages() =>
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.configPage.html",
|
||||
GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Jellyfin.Plugin.ChicoryTV.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
/// <summary>Provides the current persisted plugin configuration.</summary>
|
||||
public interface IPluginConfigurationAccessor
|
||||
{
|
||||
/// <summary>Gets the current configuration, if the plugin has initialized.</summary>
|
||||
PluginConfiguration? Configuration { get; }
|
||||
}
|
||||
|
||||
internal sealed class PluginConfigurationAccessor : IPluginConfigurationAccessor
|
||||
{
|
||||
public PluginConfiguration? Configuration => Plugin.Instance?.Configuration;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# ChicoryTV Jellyfin 12 rc2 probe
|
||||
|
||||
This standalone, read-only plugin proves the narrow Jellyfin-owned playback path for ErsatzTV issue #357. It imports ErsatzTV's JSON guide as an `ILiveTvService`. When a channel is opened, it resolves the scheduled `JellyfinItem` and returns that library item's native `MediaSourceInfo` objects so Jellyfin remains responsible for direct-play/transcode negotiation. The requested item and session remain the Jellyfin `LiveTvChannel`; the underlying library item is never returned as the client-facing item.
|
||||
|
||||
The isolated live probe proved correct source selection and Jellyfin-owned transcoding, but it also produced a no-go for a server-plugin-only continuous-channel replacement: stock channel playback starts the file at zero, and a media source negotiated before an EPG boundary is invalid afterward. This project is a default-off feasibility artifact, not a production plugin. See `docs/player-owned-playback-poc.md` for the evidence and verdict.
|
||||
|
||||
## Build and install
|
||||
|
||||
The plugin and its focused test project are part of `ErsatzTV.sln`, so the normal build catches provider-contract regressions. The plugin targets `net10.0` and pins both `Jellyfin.Controller` and `Jellyfin.Model` to exactly `12.0.0-rc2`, excluding their runtime assets as the official plugin template does.
|
||||
|
||||
```shell
|
||||
dotnet build integrations/jellyfin/Jellyfin.Plugin.ChicoryTV/Jellyfin.Plugin.ChicoryTV.csproj
|
||||
dotnet test integrations/jellyfin/Jellyfin.Plugin.ChicoryTV.Tests/Jellyfin.Plugin.ChicoryTV.Tests.csproj
|
||||
```
|
||||
|
||||
Copy `bin/Debug/net10.0/Jellyfin.Plugin.ChicoryTV.dll` (and the PDB when debugging) into its own directory below Jellyfin's plugins directory, then restart Jellyfin. In Dashboard > Plugins > ChicoryTV, configure the ErsatzTV base URL and API key. Refresh Jellyfin's Live TV guide after changing the connection.
|
||||
|
||||
The plugin is inert by default. To expose channels, the configuration page also requires an explicit acknowledgement that this unsafe lab probe cannot enforce per-user access to the backing Jellyfin items. The acknowledgement is only an opt-in flag; it is **not** an administrator-role check or any other authorization enforcement. Do not enable the probe on a shared or untrusted Jellyfin deployment.
|
||||
|
||||
The ErsatzTV side must provide:
|
||||
|
||||
- `GET /api/v1/guide`, with `Start`, `End`, and `Channels`; each channel has `Id`, `Number`, `Name`, and `Programmes`.
|
||||
- `GET /api/v1/channels/{id}/playback-source?at=...`, with the schedule-derived source contract described by issue #357. The plugin sends the configured key in `X-Api-Key`.
|
||||
|
||||
## Probe limitations
|
||||
|
||||
- Only `Source.Kind == JellyfinItem` is accepted. Local files, URLs, Plex, Emby, and unknown kinds fail explicitly; there is no proxy or ErsatzTV transcode fallback.
|
||||
- Native sources that require a nested dynamic-open operation are omitted. This first probe is for directly usable native Jellyfin library media sources.
|
||||
- `ILiveTvService` source resolution has no user parameter. This probe performs a non-user-aware library lookup, so administrators must not treat Live TV channel access as authorization for otherwise restricted backing items.
|
||||
- The unsafe-lab acknowledgement is off by default. While it is off, the service returns no channels or programmes and refuses source discovery/open calls. Enabling it does not repair the authorization gap.
|
||||
- Jellyfin requests guide programmes per channel; the plugin reuses a matching guide response for 30 seconds to avoid rebuilding the complete ErsatzTV guide for every channel in one refresh. The cache is scoped to the normalized base URL and API key and invalidates when either setting changes.
|
||||
- Jellyfin re-enters source discovery for playback and range requests. Each call resolves the item scheduled at that instant. `ILiveTvService` supplies no user or playback-session identity that could keep an old source available to an existing viewer while selecting the new source for a fresh tune. A source negotiated before an EPG boundary is therefore no longer selectable after the boundary; there is no native retune.
|
||||
- Jellyfin normalizes non-default Live TV sources as infinite/interlaced-capable and enables transcoding. Those stock-server mutations can affect negotiation even though the source metadata originates from the native library item.
|
||||
- The schedule-derived `CurrentOffsetTicks` is logged when resolving/opening, but it is not applied. The exact stock Jellyfin client sends `StartTimeTicks=0`, and `ILiveTvService` has no offset parameter. This probe makes no claim that Jellyfin consumes the logged offset.
|
||||
- A finite library file reaching EOF does not retune the channel. There is no continuous stream or transition engine.
|
||||
- Timer reads are empty and all timer mutations are unsupported. There is no recording, Kodi work, ErsatzTV FFmpeg process, or deployment automation. The plugin does not mutate the underlying item's watch history; Jellyfin may still record its normal session/user data against the `LiveTvChannel`.
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.ChicoryTV;
|
||||
|
||||
/// <summary>Registers the plugin's Live TV service.</summary>
|
||||
public sealed class ServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddSingleton(TimeProvider.System);
|
||||
serviceCollection.AddSingleton<IPluginConfigurationAccessor, PluginConfigurationAccessor>();
|
||||
serviceCollection.AddSingleton<IErsatzTvClient, ErsatzTvClient>();
|
||||
serviceCollection.AddSingleton<ILiveTvService, ErsatzTvLiveTvService>();
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -167,6 +167,7 @@ export interface components {
|
||||
"showInEpg": boolean;
|
||||
};
|
||||
"ChannelGuideChannelResponseModel": {
|
||||
"id": number;
|
||||
"number": string;
|
||||
"name": string;
|
||||
"programmes": Array<components["schemas"]["ChannelGuideProgrammeResponseModel"]>;
|
||||
@@ -197,6 +198,32 @@ export interface components {
|
||||
"title": string;
|
||||
"startUtc": string;
|
||||
"finishUtc": string;
|
||||
};
|
||||
"ChannelPlaybackItemResponseModel": {
|
||||
"playoutItemId": number;
|
||||
"mediaItemId": number;
|
||||
"start": string;
|
||||
"finish": string;
|
||||
"inPointTicks": number;
|
||||
"currentOffsetTicks": number;
|
||||
"outPointTicks": number;
|
||||
"fillerKind": components["schemas"]["FillerKind"];
|
||||
"source": components["schemas"]["ChannelPlaybackSourceReferenceResponseModel"];
|
||||
};
|
||||
"ChannelPlaybackSourceKind": "LocalFile" | "JellyfinItem" | "PlexItem" | "EmbyItem" | "RemoteUrl" | "Unsupported";
|
||||
"ChannelPlaybackSourceReferenceResponseModel": {
|
||||
"kind": components["schemas"]["ChannelPlaybackSourceKind"];
|
||||
"itemId": null | string;
|
||||
"path": null | string;
|
||||
"isLive": boolean;
|
||||
};
|
||||
"ChannelPlaybackSourceResponseModel": {
|
||||
"channelId": number;
|
||||
"sourceChannelId": number;
|
||||
"resolvedAt": string;
|
||||
"sourceAt": string;
|
||||
"nextTransitionAt": null | string;
|
||||
"active": null | components["schemas"]["ChannelPlaybackItemResponseModel"];
|
||||
};
|
||||
"ChannelPlayoutMode": "Continuous" | "OnDemand";
|
||||
"ChannelPlayoutSource": "Generated" | "Mirror";
|
||||
|
||||
Reference in New Issue
Block a user