Compare commits

...
Author SHA1 Message Date
timothyandOpenAI Codex c93404ec5c feat(jellyfin): add player-owned playback probe
Add a Jellyfin 12 rc2 ILiveTvService probe that reads ErsatzTV guide/source data and returns native Jellyfin media sources while preserving the LiveTvChannel session item.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 21:36:05 +02:00
9 changed files with 597 additions and 0 deletions
@@ -0,0 +1,20 @@
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;
}
/// <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; }
}
@@ -0,0 +1,62 @@
<!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">
<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>
<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 || '';
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();
ApiClient.updatePluginConfiguration(ChicoryTvConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
event.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,93 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Jellyfin.Plugin.ChicoryTV;
internal sealed class ErsatzTvClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _httpClient = new();
public ErsatzTvClient()
{
}
public Task<GuideResponse> GetGuideAsync(
DateTimeOffset? start,
DateTimeOffset? end,
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, cancellationToken);
}
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}"),
cancellationToken);
}
public void Dispose() => _httpClient.Dispose();
private async Task<T> GetAsync<T>(string relativePath, CancellationToken cancellationToken)
{
var configuration = Plugin.Instance?.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.");
}
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, relativePath));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrWhiteSpace(configuration.ApiKey))
{
request.Headers.TryAddWithoutValidation("X-Api-Key", configuration.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.");
}
}
@@ -0,0 +1,99 @@
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 string? Url { get; set; }
public bool IsLive { get; set; }
}
@@ -0,0 +1,207 @@
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 readonly ErsatzTvClient _client;
private readonly ILibraryManager _libraryManager;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly ILogger<ErsatzTvLiveTvService> _logger;
public ErsatzTvLiveTvService(
ErsatzTvClient client,
ILibraryManager libraryManager,
IMediaSourceManager mediaSourceManager,
ILogger<ErsatzTvLiveTvService> logger)
{
_client = client;
_libraryManager = libraryManager;
_mediaSourceManager = mediaSourceManager;
_logger = logger;
}
public string Name => "ChicoryTV";
public string HomePageUrl => "https://github.com/ErsatzTV/ErsatzTV";
public async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
{
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 (!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)
{
ResolvedMediaSources resolved = await ResolveMediaSourcesAsync(
channelId,
"resolving",
cancellationToken).ConfigureAwait(false);
return resolved.Sources;
}
public async Task<MediaSourceInfo> GetChannelStream(
string channelId,
string streamId,
CancellationToken cancellationToken)
{
ResolvedMediaSources resolved = await ResolveMediaSourcesAsync(
channelId,
"opening",
cancellationToken).ConfigureAwait(false);
MediaSourceInfo? source = string.IsNullOrWhiteSpace(streamId)
? resolved.Sources.FirstOrDefault()
: resolved.Sources.FirstOrDefault(
candidate => string.Equals(candidate.Id, streamId, StringComparison.OrdinalIgnoreCase));
return source
?? throw new InvalidOperationException(
$"Jellyfin media source '{streamId}' is no longer available for ErsatzTV channel {channelId}.");
}
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 async Task<ResolvedMediaSources> ResolveMediaSourcesAsync(
string channelId,
string operation,
CancellationToken cancellationToken)
{
if (!int.TryParse(channelId, NumberStyles.None, CultureInfo.InvariantCulture, out int parsedChannelId))
{
throw new InvalidOperationException($"ErsatzTV channel id '{channelId}' is not an integer.");
}
PlaybackSourceResponse response = await _client.GetPlaybackSourceAsync(
parsedChannelId,
DateTimeOffset.UtcNow,
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}.");
}
var item = _libraryManager.GetItemById(jellyfinItemId)
?? throw new InvalidOperationException(
$"Jellyfin library item {active.Source.ItemId} 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 {active.Source.ItemId} has no native media source usable without dynamic opening.");
}
_logger.LogInformation(
"ChicoryTV {Operation} ErsatzTV channel {ChannelId} as Jellyfin item {JellyfinItemId}; " +
"schedule-derived CurrentOffsetTicks={CurrentOffsetTicks}. ILiveTvService has no playback-offset parameter.",
operation,
response.ChannelId,
active.Source.ItemId,
active.CurrentOffsetTicks);
return new ResolvedMediaSources(sources);
}
private sealed record ResolvedMediaSources(List<MediaSourceInfo> Sources);
}
@@ -0,0 +1,27 @@
<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>
</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,28 @@
# 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.
## Build and install
The project deliberately is not part of `ErsatzTV.sln`. It 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
```
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 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.
- 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,17 @@
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<ErsatzTvClient>();
serviceCollection.AddSingleton<ILiveTvService, ErsatzTvLiveTvService>();
}
}