docs(jellyfin): record player-owned playback verdict
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 31s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 31s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Record the isolated Jellyfin 12 live evidence and the server-plugin-only no-go. Remove the discovery snapshot invalidated by Jellyfin's actual playback call path, scope the guide cache to connection settings, and fail closed for dangling mirror sources. Refs #357 Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
@@ -29,8 +29,15 @@ public class GetChannelPlaybackSourceHandler(IDbContextFactory<TvContext> dbCont
|
||||
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 ?? channel.Id
|
||||
? channel.MirrorSourceChannelId!.Value
|
||||
: channel.Id;
|
||||
TimeSpan playoutOffset = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
? channel.PlayoutOffset ?? TimeSpan.Zero
|
||||
|
||||
@@ -372,6 +372,46 @@ public class GetChannelPlaybackSourceHandlerTests
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Player-owned channel playback PoC (#357)
|
||||
|
||||
Status: phase-one Jellyfin probe. The live measurements and Kodi gate remain open.
|
||||
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
|
||||
|
||||
@@ -15,14 +17,17 @@ Jellyfin also marks provider sources as infinite Live TV. The directly usable na
|
||||
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 provisional result is therefore:
|
||||
The result is therefore:
|
||||
|
||||
- **Go** for a small installed plugin probe and for Jellyfin-owned codec negotiation on one selected item.
|
||||
- **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.
|
||||
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 non-admin users.
|
||||
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.
|
||||
|
||||
@@ -67,6 +72,8 @@ 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;
|
||||
@@ -80,8 +87,9 @@ The checked runtime is Jellyfin Server and Web `v12.0-rc2` on .NET 10.
|
||||
`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 uses nonzero `StartTimeTicks` for its native FFmpeg `-ss` path, but the plugin cannot populate that
|
||||
request field through the Live TV provider contract.
|
||||
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.
|
||||
@@ -102,9 +110,11 @@ No first-phase history toggle is required. Native Live TV playback keeps the ses
|
||||
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 must still assert start/progress/stop event IDs. If a client reports the backing item ID instead
|
||||
of the channel ID, the assumption is false and suppression becomes a separate design problem. The plugin must
|
||||
never substitute the backing library item as the playback session item.
|
||||
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
|
||||
|
||||
@@ -125,20 +135,65 @@ never substitute the backing library item as the playback session item.
|
||||
| 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 probe and evidence still required
|
||||
## Live evidence (2026-07-15)
|
||||
|
||||
The installed probe must record, without assuming success:
|
||||
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`.
|
||||
|
||||
1. two ChicoryTV channels and their EPG rows in Jellyfin;
|
||||
2. the adapter's computed offset and the actual `StartTimeTicks` sent by a stock client;
|
||||
3. direct-play/direct-stream and forced native Jellyfin-transcode process/network paths;
|
||||
4. behavior at finite-file EOF and at two scheduled boundaries;
|
||||
5. playback event item IDs and the user's Continue Watching state before/after channel surfing;
|
||||
6. startup/channel-change latency, A/V stability, visible quality, and Jellyfin/ErsatzTV CPU and memory;
|
||||
7. confirmation that no ErsatzTV FFmpeg process starts.
|
||||
### Positive results
|
||||
|
||||
Only after that evidence should the issue choose between an upstream Jellyfin seam, a thin client companion,
|
||||
or a no-go with the existing IPTV stream retained.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+15
-20
@@ -15,26 +15,16 @@ namespace Jellyfin.Plugin.ChicoryTV.Tests;
|
||||
public sealed class ErsatzTvLiveTvServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task OpenUsesDiscoverySnapshotWhenScheduleClockCrossesBoundary()
|
||||
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 sourceAForOpen = new MediaSourceInfo { Id = "source-a" };
|
||||
var sourceB = new MediaSourceInfo { Id = "source-b" };
|
||||
Movie itemA = fixture.AddItem(itemAId, sourceA);
|
||||
fixture.AddItem(itemAId, sourceA);
|
||||
fixture.AddItem(itemBId, sourceB);
|
||||
fixture.MediaSourceManager.GetPlaybackMediaSources(
|
||||
itemA,
|
||||
null!,
|
||||
true,
|
||||
true,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<MediaSourceInfo>>([sourceA]),
|
||||
Task.FromResult<IReadOnlyList<MediaSourceInfo>>([sourceAForOpen]));
|
||||
fixture.Client.GetPlaybackSourceAsync(1, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult(PlaybackResponse(now, itemAId)),
|
||||
@@ -42,18 +32,22 @@ public sealed class ErsatzTvLiveTvServiceTests
|
||||
|
||||
List<MediaSourceInfo> discovered = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
fixture.Time.Advance(TimeSpan.FromSeconds(2));
|
||||
MediaSourceInfo opened = await fixture.Service.GetChannelStream("1", "source-a", default);
|
||||
MediaSourceInfo opened = await fixture.Service.GetChannelStream("1", "source-b", default);
|
||||
|
||||
discovered.ShouldBe([sourceA]);
|
||||
opened.ShouldBeSameAs(sourceAForOpen);
|
||||
opened.ShouldBeSameAs(sourceB);
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
1,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
now,
|
||||
Arg.Any<CancellationToken>());
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
1,
|
||||
now.AddSeconds(2),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OpenRefreshesCurrentSourceAfterDiscoverySnapshotExpires()
|
||||
public async Task EachDiscoveryResolvesCurrentScheduledItem()
|
||||
{
|
||||
DateTimeOffset now = new(2026, 7, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
var fixture = new ServiceFixture(now);
|
||||
@@ -68,11 +62,12 @@ public sealed class ErsatzTvLiveTvServiceTests
|
||||
Task.FromResult(PlaybackResponse(now, itemAId)),
|
||||
Task.FromResult(PlaybackResponse(now.AddSeconds(16), itemBId)));
|
||||
|
||||
await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
List<MediaSourceInfo> first = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
fixture.Time.Advance(TimeSpan.FromSeconds(16));
|
||||
MediaSourceInfo opened = await fixture.Service.GetChannelStream("1", "source-b", default);
|
||||
List<MediaSourceInfo> second = await fixture.Service.GetChannelStreamMediaSources("1", default);
|
||||
|
||||
opened.ShouldBeSameAs(sourceB);
|
||||
first.ShouldBe([sourceA]);
|
||||
second.ShouldBe([sourceB]);
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
1,
|
||||
now.AddSeconds(16),
|
||||
@@ -101,7 +96,7 @@ public sealed class ErsatzTvLiveTvServiceTests
|
||||
|
||||
discovered.ShouldBe([first, requested]);
|
||||
opened.ShouldBeSameAs(requested);
|
||||
await fixture.Client.Received(1).GetPlaybackSourceAsync(
|
||||
await fixture.Client.Received(2).GetPlaybackSourceAsync(
|
||||
1,
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -11,19 +11,29 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient = new();
|
||||
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(
|
||||
@@ -34,15 +44,18 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
await _guideLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
GuideResponse? cached = GetCachedGuide(start, end);
|
||||
ConnectionSettings connection = GetConnectionSettings();
|
||||
GuideResponse? cached = GetCachedGuide(start, end, connection);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
GuideResponse guide = await GetGuideCoreAsync(start, end, cancellationToken).ConfigureAwait(false);
|
||||
GuideResponse guide = await GetGuideCoreAsync(start, end, connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_cachedGuide = guide;
|
||||
_cachedGuideExpiresAt = _timeProvider.GetUtcNow().AddSeconds(30);
|
||||
_cachedGuideConnection = connection;
|
||||
return guide;
|
||||
}
|
||||
finally
|
||||
@@ -61,6 +74,7 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"api/v1/channels/{channelId}/playback-source?at={timestamp}"),
|
||||
GetConnectionSettings(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -70,10 +84,15 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private GuideResponse? GetCachedGuide(DateTimeOffset? start, DateTimeOffset? end)
|
||||
private GuideResponse? GetCachedGuide(
|
||||
DateTimeOffset? start,
|
||||
DateTimeOffset? end,
|
||||
ConnectionSettings connection)
|
||||
{
|
||||
GuideResponse? cached = _cachedGuide;
|
||||
if (cached is null || _timeProvider.GetUtcNow() >= _cachedGuideExpiresAt)
|
||||
if (cached is null
|
||||
|| _cachedGuideConnection != connection
|
||||
|| _timeProvider.GetUtcNow() >= _cachedGuideExpiresAt)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -87,6 +106,7 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
private Task<GuideResponse> GetGuideCoreAsync(
|
||||
DateTimeOffset? start,
|
||||
DateTimeOffset? end,
|
||||
ConnectionSettings connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var relativePath = "api/v1/guide";
|
||||
@@ -98,10 +118,10 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
$"&end={Uri.EscapeDataString(end.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture))}");
|
||||
}
|
||||
|
||||
return GetAsync<GuideResponse>(relativePath, cancellationToken);
|
||||
return GetAsync<GuideResponse>(relativePath, connection, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<T> GetAsync<T>(string relativePath, CancellationToken cancellationToken)
|
||||
private ConnectionSettings GetConnectionSettings()
|
||||
{
|
||||
var configuration = _configurationAccessor.Configuration
|
||||
?? throw new InvalidOperationException("The ChicoryTV plugin has not been initialized.");
|
||||
@@ -112,11 +132,19 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
throw new InvalidOperationException("Configure an absolute HTTP or HTTPS ErsatzTV base URL.");
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, relativePath));
|
||||
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(configuration.ApiKey))
|
||||
if (!string.IsNullOrWhiteSpace(connection.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Api-Key", configuration.ApiKey);
|
||||
request.Headers.TryAddWithoutValidation("X-Api-Key", connection.ApiKey);
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(
|
||||
@@ -142,4 +170,6 @@ internal sealed class ErsatzTvClient : IErsatzTvClient, IDisposable
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
@@ -14,15 +13,12 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
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 static readonly TimeSpan DiscoverySnapshotLifetime = TimeSpan.FromSeconds(15);
|
||||
|
||||
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 readonly ConcurrentDictionary<int, DiscoverySnapshot> _discoverySnapshots = new();
|
||||
private int _disabledWarningLogged;
|
||||
|
||||
public ErsatzTvLiveTvService(
|
||||
@@ -115,13 +111,7 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
ResolvedMediaSources resolved = await ResolveMediaSourcesAsync(
|
||||
parsedChannelId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_discoverySnapshots[parsedChannelId] = new DiscoverySnapshot(
|
||||
resolved.ChannelId,
|
||||
resolved.JellyfinItemId,
|
||||
resolved.JellyfinItemGuid,
|
||||
resolved.CurrentOffsetTicks,
|
||||
resolved.ExpiresAt);
|
||||
LogResolution("discovering", resolved, usesDiscoverySnapshot: false);
|
||||
LogResolution("discovering", resolved);
|
||||
return [.. resolved.Sources];
|
||||
}
|
||||
|
||||
@@ -132,40 +122,9 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
{
|
||||
EnsureUnsafeLabAccessEnabled();
|
||||
int parsedChannelId = ParseChannelId(channelId);
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
ResolvedMediaSources resolved;
|
||||
bool usesDiscoverySnapshot;
|
||||
if (_discoverySnapshots.TryGetValue(parsedChannelId, out DiscoverySnapshot? cached)
|
||||
&& cached is not null
|
||||
&& now < cached.ExpiresAt)
|
||||
{
|
||||
List<MediaSourceInfo> sources = await GetNativeMediaSourcesAsync(
|
||||
cached.ChannelId,
|
||||
cached.JellyfinItemId,
|
||||
cached.JellyfinItemGuid,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
resolved = new ResolvedMediaSources(
|
||||
cached.ChannelId,
|
||||
cached.JellyfinItemId,
|
||||
cached.JellyfinItemGuid,
|
||||
cached.CurrentOffsetTicks,
|
||||
sources,
|
||||
cached.ExpiresAt);
|
||||
usesDiscoverySnapshot = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolved = await ResolveMediaSourcesAsync(
|
||||
parsedChannelId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_discoverySnapshots[parsedChannelId] = new DiscoverySnapshot(
|
||||
resolved.ChannelId,
|
||||
resolved.JellyfinItemId,
|
||||
resolved.JellyfinItemGuid,
|
||||
resolved.CurrentOffsetTicks,
|
||||
resolved.ExpiresAt);
|
||||
usesDiscoverySnapshot = false;
|
||||
}
|
||||
ResolvedMediaSources resolved = await ResolveMediaSourcesAsync(
|
||||
parsedChannelId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MediaSourceInfo? source = string.IsNullOrWhiteSpace(streamId)
|
||||
? resolved.Sources.FirstOrDefault()
|
||||
@@ -177,7 +136,7 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
$"Jellyfin media source '{streamId}' is no longer available for ErsatzTV channel {channelId}.");
|
||||
}
|
||||
|
||||
LogResolution("opening", resolved, usesDiscoverySnapshot);
|
||||
LogResolution("opening", resolved);
|
||||
return source;
|
||||
}
|
||||
|
||||
@@ -254,10 +213,8 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
return new ResolvedMediaSources(
|
||||
response.ChannelId,
|
||||
active.Source.ItemId,
|
||||
jellyfinItemId,
|
||||
active.CurrentOffsetTicks,
|
||||
sources,
|
||||
_timeProvider.GetUtcNow().Add(DiscoverySnapshotLifetime));
|
||||
sources);
|
||||
}
|
||||
|
||||
private async Task<List<MediaSourceInfo>> GetNativeMediaSourcesAsync(
|
||||
@@ -292,16 +249,8 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
return sources;
|
||||
}
|
||||
|
||||
private bool IsUnsafeLabAccessEnabled()
|
||||
{
|
||||
bool enabled = _configurationAccessor.Configuration?.EnableUnsafeLabAccess == true;
|
||||
if (!enabled)
|
||||
{
|
||||
_discoverySnapshots.Clear();
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
private bool IsUnsafeLabAccessEnabled() =>
|
||||
_configurationAccessor.Configuration?.EnableUnsafeLabAccess == true;
|
||||
|
||||
private void EnsureUnsafeLabAccessEnabled()
|
||||
{
|
||||
@@ -320,34 +269,21 @@ internal sealed class ErsatzTvLiveTvService : ILiveTvService
|
||||
}
|
||||
}
|
||||
|
||||
private void LogResolution(
|
||||
string operation,
|
||||
ResolvedMediaSources resolved,
|
||||
bool usesDiscoverySnapshot)
|
||||
private void LogResolution(string operation, ResolvedMediaSources resolved)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ChicoryTV {Operation} ErsatzTV channel {ChannelId} as Jellyfin item {JellyfinItemId}; " +
|
||||
"schedule-derived CurrentOffsetTicks={CurrentOffsetTicks}. " +
|
||||
"UsedDiscoverySnapshot={UsedDiscoverySnapshot}. ILiveTvService has no playback-offset parameter.",
|
||||
"ILiveTvService has no playback-offset parameter.",
|
||||
operation,
|
||||
resolved.ChannelId,
|
||||
resolved.JellyfinItemId,
|
||||
resolved.CurrentOffsetTicks,
|
||||
usesDiscoverySnapshot);
|
||||
resolved.CurrentOffsetTicks);
|
||||
}
|
||||
|
||||
private sealed record ResolvedMediaSources(
|
||||
int ChannelId,
|
||||
string JellyfinItemId,
|
||||
Guid JellyfinItemGuid,
|
||||
long CurrentOffsetTicks,
|
||||
List<MediaSourceInfo> Sources,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed record DiscoverySnapshot(
|
||||
int ChannelId,
|
||||
string JellyfinItemId,
|
||||
Guid JellyfinItemGuid,
|
||||
long CurrentOffsetTicks,
|
||||
DateTimeOffset ExpiresAt);
|
||||
List<MediaSourceInfo> Sources);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
@@ -26,8 +28,8 @@ The ErsatzTV side must provide:
|
||||
- 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.
|
||||
- Source discovery keeps only the resolved schedule identity per channel for 15 seconds and rehydrates fresh native media-source objects on open. An immediately following open therefore selects the same scheduled item even if the schedule clock crosses a boundary between the two calls, without sharing Jellyfin's mutable source objects across requests. After expiration, open resolves the current item again. This handshake grace does not retune a running stream or implement schedule transitions.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user