using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Chaosflix.Api;
using Jellyfin.Plugin.Chaosflix.Api.Models;
using Jellyfin.Plugin.Chaosflix.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Plugins;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Chaosflix.Channel;
///
/// Jellyfin channel that provides CCC media content.
///
public partial class ChaosflixChannel : IChannel, IRequiresMediaInfoCallback, ISupportsLatestMedia, IHasCacheKey
{
private const string FolderPopular = "virtual:popular";
private const string FolderLive = "virtual:live";
internal const string FolderBrowseByYear = "virtual:years";
private const string FolderRecommended = "virtual:recommended";
private const string PrefixConference = "conf:";
private const string PrefixEvent = "event:";
private const string PrefixYear = "year:";
private const string PrefixRelated = "related:";
private const string PrefixLive = "live:";
private const string ScopePopular = "popular";
private const string ScopeRecommended = "recommended";
private const string ConferenceScopePrefix = "conf-";
/// The channel's display name, which is also how Jellyfin identifies it.
internal const string ChannelName = "Chaosflix";
private const string GenericSlugSegment = "conferences";
internal const int ProbeCacheCapacity = 128;
// A talk on media.ccc.de has a handful of recordings; the cap only keeps a
// pathological one from filling a version list nobody can read.
internal const int MaxSelectableSources = 8;
///
/// The subtitle formats media.ccc.de publishes, mapped to the codec name an
/// external subtitle carries in Jellyfin: the file extension, not the ffmpeg
/// codec. A client only keeps the url we hand it when that name matches the
/// format in its subtitle profile, and every client spells them this way.
///
private static readonly char[] FilterSeparators = [',', ';', '\n', '\r'];
///
/// How late a room going on or off air may show in the channel. It is the price of not
/// re-asking every folder on every browse; a talk runs far longer than this.
///
internal static readonly TimeSpan LiveFolderVisibilityDelay = TimeSpan.FromMinutes(5);
/// Protocols taken from a live room, in the order they are preferred.
private static readonly string[] LiveProtocols = ["hls", "webm"];
/// Player types of a live room, in the order they are offered.
private static readonly string[] LiveTypeOrder = ["video", "slides", "audio", "music"];
private static readonly Dictionary SubtitleCodecs = new(StringComparer.OrdinalIgnoreCase)
{
["text/vtt"] = "vtt",
["text/webvtt"] = "vtt",
["application/x-subrip"] = "srt",
["text/srt"] = "srt",
["application/x-subtitle"] = "srt"
};
private static readonly Dictionary LanguageNames = new(StringComparer.OrdinalIgnoreCase)
{
["deu"] = "Deutsch",
["ger"] = "Deutsch",
["de"] = "Deutsch",
["eng"] = "English",
["en"] = "English",
["fra"] = "Français",
["fre"] = "Français",
["fr"] = "Français",
["spa"] = "Español",
["nld"] = "Nederlands",
["dut"] = "Nederlands",
["gsw"] = "Schwyzerdütsch"
};
internal static readonly TimeSpan ProbeCacheTtl = TimeSpan.FromHours(6);
// Jellyfin's ChannelManager keeps what GetChannelItemMediaInfo returned in
// the shared IMemoryCache under the plain channel item id for five minutes
// and nothing — not even a full metadata refresh — drops it (#36). We keep
// the ids we handed out a little longer than that so a configuration change
// can evict exactly those entries.
internal static readonly TimeSpan ServedIdRetention = TimeSpan.FromMinutes(10);
/// How far the copies outside the conference folders are filed in the past; see .
internal static readonly TimeSpan NonCanonicalBackdate = TimeSpan.FromDays(365 * 100);
private readonly CccApiClient _apiClient;
private readonly ILogger _logger;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly TimeProvider _timeProvider;
private readonly IMemoryCache? _mediaSourceCache;
private readonly ILibraryManager? _libraryManager;
private readonly ConcurrentDictionary _probeCache = new();
private readonly ConcurrentDictionary _servedItemIds = new();
private readonly Lock _subscriptionLock = new();
private Plugin? _subscribedPlugin;
private long _probeHits;
private long _probeMisses;
///
/// Initializes a new instance of the class.
///
public ChaosflixChannel(
CccApiClient apiClient,
ILogger logger,
IServerApplicationHost appHost,
IMediaEncoder mediaEncoder,
TimeProvider? timeProvider = null,
IMemoryCache? mediaSourceCache = null,
ILibraryManager? libraryManager = null)
{
_apiClient = apiClient;
_logger = logger;
_appHost = appHost;
_mediaEncoder = mediaEncoder;
_timeProvider = timeProvider ?? TimeProvider.System;
_mediaSourceCache = mediaSourceCache;
_libraryManager = libraryManager;
}
///
public string Name => ChannelName;
///
public string Description => "Chaos Computer Club talks from media.ccc.de";
///
public string DataVersion => "7";
///
public string HomePageUrl => "https://media.ccc.de";
///
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
///
public InternalChannelFeatures GetChannelFeatures()
{
return new InternalChannelFeatures
{
MediaTypes = new List { ChannelMediaType.Video },
ContentTypes = new List { ChannelMediaContentType.Clip },
MaxPageSize = 100,
DefaultSortFields = new List
{
ChannelItemSortField.DateCreated,
ChannelItemSortField.Name
},
SupportsSortOrderToggle = true,
AutoRefreshLevels = 3
};
}
///
/// Jellyfin keeps a folder listing on disk for three hours unless the channel hands it
/// a key that changes, and "🔴 Live now" has to come and go with the schedule rather
/// than three hours behind it (#71).
///
/// The key carries a coarse time bucket: fine enough for a folder that stands for the
/// length of a congress, coarse enough that browsing does not re-ask every folder on
/// every click. It also carries the streaming endpoint, so pointing the plugin at a
/// different one takes effect at once instead of within the bucket.
///
public string? GetCacheKey(string? userId)
{
var bucket = _timeProvider.GetUtcNow().Ticks / LiveFolderVisibilityDelay.Ticks;
return $"{bucket}{DeterministicGuid(CccApiClient.StreamingBaseUrl).ToString("N")[..8]}";
}
///
public bool IsEnabledFor(string userId) => true;
///
public IEnumerable GetSupportedChannelImages()
{
return new[] { ImageType.Primary, ImageType.Thumb };
}
///
public Task GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
return Task.FromResult(new DynamicImageResponse
{
Path = "https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/assets/logo.svg",
Protocol = MediaProtocol.Http,
HasImage = true
});
}
///
public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
_logger.LogDebug("GetChannelItems: FolderId={FolderId}", query.FolderId);
if (string.IsNullOrEmpty(query.FolderId))
{
return await GetRootItems(cancellationToken).ConfigureAwait(false);
}
return query.FolderId switch
{
FolderLive => await GetLiveItems(cancellationToken).ConfigureAwait(false),
FolderPopular => await GetPopularItems(cancellationToken).ConfigureAwait(false),
FolderBrowseByYear => await GetYearFolders(cancellationToken).ConfigureAwait(false),
FolderRecommended => await GetRecommendedItems(cancellationToken).ConfigureAwait(false),
_ when query.FolderId.StartsWith(PrefixYear, StringComparison.Ordinal)
=> await GetConferencesByYear(query.FolderId[5..], cancellationToken).ConfigureAwait(false),
_ when query.FolderId.StartsWith(PrefixConference, StringComparison.Ordinal)
=> await GetConferenceItems(query.FolderId[5..], cancellationToken).ConfigureAwait(false),
_ when query.FolderId.StartsWith(PrefixRelated, StringComparison.Ordinal)
=> await GetRelatedItems(query.FolderId[8..], cancellationToken).ConfigureAwait(false),
_ => new ChannelItemResult()
};
}
///
public async Task> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
{
_logger.LogDebug("GetChannelItemMediaInfo: {Id}", id);
SubscribeToConfigurationChanges();
RememberServedId(id);
if (id.StartsWith(PrefixLive, StringComparison.Ordinal))
{
return await LiveSourcesAsync(id, cancellationToken).ConfigureAwait(false);
}
var eventGuid = ExtractEventGuid(id);
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
if (cccEvent?.Recordings == null)
{
return Enumerable.Empty();
}
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var serverUrl = _appHost.GetSmartApiUrl(string.Empty).TrimEnd('/');
return await SelectRecordingsAsync(cccEvent.Recordings, config, eventGuid, serverUrl, ResolveItemId(id), cancellationToken).ConfigureAwait(false);
}
///
public async Task> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
{
var conferences = await SelectedConferencesAsync(cancellationToken).ConfigureAwait(false);
// Get the 3 most recently updated conferences for a broader latest view
var recentConferences = conferences
.Where(c => c.EventLastReleasedAt != null)
.OrderByDescending(c => c.EventLastReleasedAt)
.Take(3)
.ToList();
var allEvents = new List<(CccEvent Event, string Acronym)>();
foreach (var conf in recentConferences)
{
var detail = await _apiClient.GetConferenceAsync(conf.Acronym, cancellationToken).ConfigureAwait(false);
if (detail?.Events != null)
{
allEvents.AddRange(detail.Events.Select(e => (e, conf.Acronym)));
}
}
// The latest row points at the items the conference folders own, so it
// does not create a second copy of the same talk.
return allEvents
.OrderByDescending(x => x.Event.ReleaseDate ?? x.Event.Date)
.Take(20)
.Select(x => MapEventToChannelItem(x.Event, ConferenceScope(x.Acronym)));
}
// ── Root ──────────────────────────────────────────────
private async Task GetRootItems(CancellationToken cancellationToken)
{
var items = new List
{
new ChannelItemInfo
{
Name = "🔥 Popular Talks",
Id = FolderPopular,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
Overview = "Most viewed talks across all conferences"
},
new ChannelItemInfo
{
Name = "⭐ Recommended",
Id = FolderRecommended,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
Overview = "Highly rated recent talks"
},
new ChannelItemInfo
{
Name = "📅 Browse by Year",
Id = FolderBrowseByYear,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
Overview = "All conferences grouped by year"
}
};
// Only while something is on air: an empty "Live now" folder sitting there all
// year would be worse than no folder at all (#71).
if ((await LiveRoomsAsync(cancellationToken).ConfigureAwait(false)).Count > 0)
{
items.Insert(0, new ChannelItemInfo
{
Name = "🔴 Live now",
Id = FolderLive,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
Overview = "Rooms streaming right now"
});
}
return new ChannelItemResult
{
Items = items,
TotalRecordCount = items.Count
};
}
// ── Live ─────────────────────────────────────────────
///
/// Every room on air, paired with the conference it belongs to. A conference that
/// says it is not currently streaming is skipped even if it still lists rooms, and so
/// is a room with nothing playable in it.
///
private async Task> LiveRoomsAsync(
CancellationToken cancellationToken)
{
var conferences = await _apiClient.GetLiveConferencesAsync(cancellationToken).ConfigureAwait(false);
return conferences
.Where(c => c.IsCurrentlyStreaming)
.SelectMany(c => c.Groups.SelectMany(g => g.Rooms).Select(r => (Conference: c, Room: r)))
.Where(x => PlayableStreams(x.Room).Count > 0)
.ToList();
}
///
/// The stream variants worth offering, best first: pictures before slides before
/// audio, the floor language before an interpreter's channel, and the larger picture
/// first. Only HLS and WebM are kept — DASH needs a manifest reader Jellyfin does not
/// bring for a channel item.
///
private static List<(CccLiveStreamVariant Variant, string Protocol, string Url)> PlayableStreams(CccLiveRoom room)
{
var playable = new List<(CccLiveStreamVariant Variant, string Protocol, string Url)>();
foreach (var variant in room.Streams)
{
foreach (var protocol in LiveProtocols)
{
if (variant.Urls.TryGetValue(protocol, out var url) && !string.IsNullOrWhiteSpace(url.Url))
{
playable.Add((variant, protocol, url.Url));
break;
}
}
}
return playable
.OrderBy(x => Array.IndexOf(LiveTypeOrder, x.Variant.Type) is var rank && rank >= 0 ? rank : LiveTypeOrder.Length)
.ThenBy(x => x.Variant.IsTranslated ? 1 : 0)
.ThenByDescending(x => x.Variant.VideoSize is { Count: > 0 } size ? size[0] : 0)
.ThenBy(x => x.Variant.Slug, StringComparer.Ordinal)
.ToList();
}
private async Task GetLiveItems(CancellationToken cancellationToken)
{
var items = (await LiveRoomsAsync(cancellationToken).ConfigureAwait(false))
.Select(x => MapRoomToChannelItem(x.Conference, x.Room))
.ToList();
return new ChannelItemResult { Items = items, TotalRecordCount = items.Count };
}
private static ChannelItemInfo MapRoomToChannelItem(CccLiveConference conference, CccLiveRoom room)
{
var current = room.Talks?.Current?.Title;
return new ChannelItemInfo
{
Name = string.IsNullOrWhiteSpace(current) ? room.Display : $"{room.Display}: {current}",
Id = $"{PrefixLive}{conference.Slug}:{room.Slug}",
Type = ChannelItemType.Media,
MediaType = ChannelMediaType.Video,
ContentType = ChannelMediaContentType.Clip,
IsLiveStream = true,
ImageUrl = room.Thumb,
HomePageUrl = room.Link,
SeriesName = conference.Conference,
Overview = BuildLiveOverview(conference, room),
// No RunTimeTicks on purpose: a live room has no length, and a number here
// would make clients draw a progress bar over something that never ends.
};
}
private static string BuildLiveOverview(CccLiveConference conference, CccLiveRoom room)
{
var lines = new List { $"{conference.Conference} · {room.Display} · live" };
if (room.Talks?.Current is { } current && !string.IsNullOrWhiteSpace(current.Title))
{
lines.Add(string.IsNullOrWhiteSpace(current.Speaker)
? $"Now: {current.Title}"
: $"Now: {current.Title} — {current.Speaker}");
}
if (room.Talks?.Next is { } next && !string.IsNullOrWhiteSpace(next.Title))
{
lines.Add(string.IsNullOrWhiteSpace(next.Speaker)
? $"Next: {next.Title}"
: $"Next: {next.Title} — {next.Speaker}");
}
return string.Join("\n", lines);
}
///
/// The media sources of a live room, straight from c3voc. These are not proxied: the
/// reason the recordings are (a cross-domain CDN redirect ExoPlayer will not follow)
/// does not apply to an HLS manifest served directly.
///
private async Task> LiveSourcesAsync(string id, CancellationToken cancellationToken)
{
var parts = id[PrefixLive.Length..].Split(':', 2);
if (parts.Length != 2)
{
return new List();
}
var match = (await LiveRoomsAsync(cancellationToken).ConfigureAwait(false))
.FirstOrDefault(x =>
string.Equals(x.Conference.Slug, parts[0], StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Room.Slug, parts[1], StringComparison.OrdinalIgnoreCase));
if (match.Room == null)
{
_logger.LogDebug("Live room {Id} is no longer on air", id);
return new List();
}
return PlayableStreams(match.Room)
.Select((stream, i) => new MediaSourceInfo
{
Id = DeterministicGuid($"{id}|{stream.Variant.Slug}|{stream.Protocol}").ToString("N"),
Name = LiveSourceName(stream.Variant, stream.Protocol),
Path = stream.Url,
Protocol = MediaProtocol.Http,
Container = stream.Protocol == "hls" ? "ts" : "webm",
IsInfiniteStream = true,
IsRemote = true,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = true,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
VideoType = VideoType.VideoFile
})
.ToList();
}
private static string LiveSourceName(CccLiveStreamVariant variant, string protocol)
{
var quality = variant.VideoSize is { Count: > 1 } size ? $"{size[0]}x{size[1]}" : variant.Type;
var language = variant.IsTranslated ? "translated" : "native";
return $"{quality} · {language} · {protocol.ToUpperInvariant()}";
}
// ── Popular ──────────────────────────────────────────
private async Task GetPopularItems(CancellationToken cancellationToken)
{
var conferences = await SelectedConferencesAsync(cancellationToken).ConfigureAwait(false);
// Fetch events from the 5 most recent conferences
var recentConferences = conferences
.Where(c => c.EventLastReleasedAt != null)
.OrderByDescending(c => c.EventLastReleasedAt)
.Take(5)
.ToList();
var allEvents = new List();
foreach (var conf in recentConferences)
{
var detail = await _apiClient.GetConferenceAsync(conf.Acronym, cancellationToken).ConfigureAwait(false);
if (detail?.Events != null)
{
allEvents.AddRange(detail.Events);
}
}
var items = allEvents
.OrderByDescending(e => e.ViewCount)
.ThenBy(e => e.Guid, StringComparer.Ordinal)
.Take(50)
.Select(e => MapEventToChannelItem(e, ScopePopular))
.ToList();
return new ChannelItemResult { Items = items, TotalRecordCount = items.Count };
}
// ── Recommended (high views + recent) ────────────────
private async Task GetRecommendedItems(CancellationToken cancellationToken)
{
var conferences = await SelectedConferencesAsync(cancellationToken).ConfigureAwait(false);
var recentConferences = conferences
.Where(c => c.EventLastReleasedAt != null)
.OrderByDescending(c => c.EventLastReleasedAt)
.Take(3)
.ToList();
var allEvents = new List();
foreach (var conf in recentConferences)
{
var detail = await _apiClient.GetConferenceAsync(conf.Acronym, cancellationToken).ConfigureAwait(false);
if (detail?.Events != null)
{
allEvents.AddRange(detail.Events);
}
}
// Score: views * recency boost, in whole days. A continuous age moves the
// score between two refreshes that see identical data, so the talks either
// side of the cut swap places and Jellyfin removes and re-adds them every
// time — and logs a missing image for the copy it has just deleted (#62).
var now = _timeProvider.GetUtcNow();
var items = allEvents
.Where(e => e.ViewCount > 100)
.OrderByDescending(e =>
{
var ageDays = Math.Max(1, Math.Floor((now - (e.ReleaseDate ?? e.Date ?? now)).TotalDays));
return e.ViewCount / Math.Sqrt(ageDays);
})
.ThenBy(e => e.Guid, StringComparer.Ordinal)
.Take(30)
.Select(e => MapEventToChannelItem(e, ScopeRecommended))
.ToList();
return new ChannelItemResult { Items = items, TotalRecordCount = items.Count };
}
///
/// The conferences the user asked for, or all of them when nothing is configured.
/// Every path into the channel goes through here, so Popular and Recommended are
/// drawn from the same set as the folders: a user who only follows Congress has no
/// use for a FOSSGIS talk in their Popular row, and fewer conferences also means
/// fewer copies of a talk in the library (#86).
///
private async Task> SelectedConferencesAsync(CancellationToken cancellationToken)
{
var all = await _apiClient.GetConferencesAsync(cancellationToken).ConfigureAwait(false);
return SelectConferences(all, Plugin.Instance?.Configuration.ConferenceFilter);
}
internal static List SelectConferences(
IEnumerable conferences, string? filter)
{
var wanted = (filter ?? string.Empty)
.Split(FilterSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return wanted.Count == 0
? conferences.ToList()
: conferences.Where(c => Matches(c, wanted)).ToList();
}
///
/// An entry matches the acronym, or any segment of the slug the conference is filed
/// under. The slugs are not uniform — "congress/2024" but "conferences/gpn/gpn22" and
/// "conferences/geo/fossgis2023" — so a fixed position would pick the wrong word;
/// matching any segment lets "congress" and "gpn" both stand for the series.
///
private static bool Matches(CccConference conference, HashSet wanted)
{
if (wanted.Contains(conference.Acronym))
{
return true;
}
foreach (var segment in conference.Slug.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
// Almost everything sits under "conferences/", so honouring that as a series
// would select the whole catalogue and silently turn the filter off.
if (!segment.Equals(GenericSlugSegment, StringComparison.OrdinalIgnoreCase)
&& wanted.Contains(segment))
{
return true;
}
}
return false;
}
// ── Year grouping ────────────────────────────────────
private async Task GetYearFolders(CancellationToken cancellationToken)
{
var conferences = await SelectedConferencesAsync(cancellationToken).ConfigureAwait(false);
var years = conferences
.Where(c => c.EventLastReleasedAt != null)
.Select(c => c.EventLastReleasedAt!.Value.Year)
.Distinct()
.OrderByDescending(y => y)
.Select(y => new ChannelItemInfo
{
Name = y.ToString(CultureInfo.InvariantCulture),
Id = $"{PrefixYear}{y}",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container
})
.ToList();
return new ChannelItemResult { Items = years, TotalRecordCount = years.Count };
}
private async Task GetConferencesByYear(string yearStr, CancellationToken cancellationToken)
{
if (!int.TryParse(yearStr, out var year))
{
return new ChannelItemResult();
}
var conferences = await SelectedConferencesAsync(cancellationToken).ConfigureAwait(false);
var items = conferences
.Where(c => c.EventLastReleasedAt != null && c.EventLastReleasedAt.Value.Year == year)
.OrderByDescending(c => c.EventLastReleasedAt)
.Select(c => new ChannelItemInfo
{
Name = c.Title,
Id = $"{PrefixConference}{c.Acronym}",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = c.LogoUrl,
Overview = c.Description,
DateCreated = c.EventLastReleasedAt?.DateTime
})
.ToList();
return new ChannelItemResult { Items = items, TotalRecordCount = items.Count };
}
// ── Conference events ────────────────────────────────
private async Task GetConferenceItems(string acronym, CancellationToken cancellationToken)
{
var conference = await _apiClient.GetConferenceAsync(acronym, cancellationToken).ConfigureAwait(false);
if (conference?.Events == null)
{
return new ChannelItemResult();
}
var items = conference.Events
.OrderByDescending(e => e.Date)
.Select(e => MapEventToChannelItem(e, ConferenceScope(acronym)))
.ToList();
return new ChannelItemResult { Items = items, TotalRecordCount = items.Count };
}
// ── Related talks ────────────────────────────────────
private async Task GetRelatedItems(string eventGuid, CancellationToken cancellationToken)
{
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
if (cccEvent?.Related == null || cccEvent.Related.Count == 0)
{
return new ChannelItemResult();
}
var relatedGuids = cccEvent.Related
.OrderByDescending(r => r.Weight)
.Take(15)
.Select(r => r.EventGuid)
.ToList();
var items = new List();
foreach (var guid in relatedGuids)
{
var related = await _apiClient.GetEventAsync(guid, cancellationToken).ConfigureAwait(false);
if (related != null)
{
items.Add(MapEventToChannelItem(related, RelatedScope(eventGuid)));
}
}
return new ChannelItemResult { Items = items, TotalRecordCount = items.Count };
}
// ── Item ids ─────────────────────────────────────────
// Jellyfin stores a channel item as one library item with exactly one
// parent, so a talk listed in several folders under the same id is moved to
// whichever folder was listed last and vanishes from the others (#15).
// Every folder therefore hands out its own id for a talk.
///
/// The id of a conference's folder, as handed out by
/// and stored on the library item as its ExternalId.
///
/// The id of a talk's copy in a conference folder, as the tests spell it.
internal static string ConferenceItemId(string eventGuid, string acronym = "c") =>
$"{PrefixEvent}{ConferenceScope(acronym)}:{eventGuid}";
internal static string ConferenceFolderId(string acronym) => $"{PrefixConference}{acronym}";
private static string ConferenceScope(string acronym) => $"{ConferenceScopePrefix}{acronym}";
private static string RelatedScope(string eventGuid) => $"related-{eventGuid}";
private static bool IsConferenceScope(string scope) => scope.StartsWith(ConferenceScopePrefix, StringComparison.Ordinal);
///
/// Reads the CCC event guid back out of a channel item id. Accepts the
/// scoped form event:<scope>:<guid>, the unscoped
/// event:<guid> and a bare guid.
///
private static string ExtractEventGuid(string id)
{
var separator = id.LastIndexOf(':');
return separator < 0 ? id : id[(separator + 1)..];
}
///
/// True for the copy of a talk that lives in its conference folder. A talk is
/// listed in several folders and each listing is its own library item, so
/// anything pointing at "the" talk has to pick one; the conference copy is the
/// one that carries the real date and the one the latest row points at.
///
internal static bool IsConferenceCopy(string? channelItemId)
{
if (string.IsNullOrEmpty(channelItemId)
|| !channelItemId.StartsWith(PrefixEvent, StringComparison.Ordinal))
{
return false;
}
var scoped = channelItemId[PrefixEvent.Length..];
var separator = scoped.LastIndexOf(':');
return separator > 0 && IsConferenceScope(scoped[..separator]);
}
///
/// Reads the CCC event guid out of a channel item id that the channel handed
/// out, or returns for anything else.
///
internal static string? EventGuidOf(string? channelItemId)
{
if (string.IsNullOrEmpty(channelItemId) || !channelItemId.StartsWith(PrefixEvent, StringComparison.Ordinal))
{
return null;
}
var guid = ExtractEventGuid(channelItemId);
return string.IsNullOrEmpty(guid) ? null : guid;
}
// ── Mapping ──────────────────────────────────────────
///
/// The date a talk is filed under in its folder's copy. "Recently added in
/// Chaosflix" is a plain library query — measured on 10.11.7 and 12.1 it is
/// /Users/{id}/Items/Latest?ParentId=<channel> — so it lists every
/// folder-scoped copy of a talk (#54, #15). Backdating the copies outside the
/// conference folders keeps them out of any recency-sorted view while the
/// conference copy, the one also points at, stays
/// where it belongs. The shift is constant, so the order inside Popular,
/// Recommended and Related is untouched. A talk the API gives no date for is
/// filed at the very bottom instead: left empty, Jellyfin stamps the item
/// with the moment it was stored, which puts it at the top of the row.
///
private static DateTime? ScopedDateCreated(DateTime? releasedAt, string scope)
{
if (IsConferenceScope(scope))
{
return releasedAt;
}
if (releasedAt is not DateTime date || date - DateTime.MinValue <= NonCanonicalBackdate)
{
return DateTime.MinValue;
}
return date - NonCanonicalBackdate;
}
private static ChannelItemInfo MapEventToChannelItem(CccEvent e, string scope)
{
var info = new ChannelItemInfo
{
Name = e.Title,
Id = $"{PrefixEvent}{scope}:{e.Guid}",
Type = ChannelItemType.Media,
MediaType = ChannelMediaType.Video,
ContentType = ChannelMediaContentType.Clip,
ImageUrl = e.PosterUrl ?? e.ThumbUrl,
Overview = BuildOverview(e),
RunTimeTicks = (long)e.Duration * TimeSpan.TicksPerSecond,
DateCreated = ScopedDateCreated(e.Date?.DateTime ?? e.ReleaseDate?.DateTime, scope),
HomePageUrl = e.FrontendLink,
OriginalTitle = e.Subtitle,
SeriesName = e.ConferenceTitle
};
// Tags as genres (filter noise)
foreach (var tag in e.Tags.Where(t =>
!int.TryParse(t, out _) &&
!t.StartsWith("Stage", StringComparison.OrdinalIgnoreCase) &&
!YearPattern().IsMatch(t) &&
t.Length > 2))
{
info.Genres.Add(tag);
}
// Speakers
foreach (var person in e.Persons)
{
info.People.Add(new MediaBrowser.Controller.Entities.PersonInfo
{
Name = person,
Type = Jellyfin.Data.Enums.PersonKind.Actor
});
}
return info;
}
private static string BuildOverview(CccEvent e)
{
var parts = new List();
if (!string.IsNullOrEmpty(e.ConferenceTitle))
{
parts.Add(e.ConferenceTitle);
}
if (e.Persons.Count > 0)
{
parts.Add($"Speaker: {string.Join(", ", e.Persons)}");
}
if (e.ViewCount > 0)
{
parts.Add($"{e.ViewCount:N0} views");
}
if (!string.IsNullOrEmpty(e.OriginalLanguage))
{
parts.Add($"Language: {e.OriginalLanguage}");
}
var header = string.Join(" · ", parts);
var desc = e.Description ?? string.Empty;
// Add "Related Talks" hint if event has related content
if (e.Related != null && e.Related.Count > 0)
{
desc += $"\n\n→ {e.Related.Count} related talks available";
}
return string.IsNullOrEmpty(header) ? desc : $"{header}\n\n{desc}";
}
// ── Recording selection ──────────────────────────────
private async Task> SelectRecordingsAsync(
List recordings,
PluginConfiguration config,
string eventGuid,
string serverUrl,
Guid? itemId,
CancellationToken cancellationToken)
{
var videoRecordings = recordings
.Where(r => r.MimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
.ToList();
if (videoRecordings.Count == 0)
{
return new List();
}
// Every usable recording becomes a source so a viewer can pick another
// quality or language per device; the configuration decides the order,
// and Jellyfin plays the first one unless the viewer says otherwise.
var ranked = RankRecordings(videoRecordings, config);
if (config.CompatibleDefaultVersion)
{
PromoteCompatibleDefault(ranked);
}
ranked = ranked.Take(MaxSelectableSources).ToList();
var names = SourceNames(ranked);
var subtitles = SelectSubtitleRecordings(recordings);
var sources = new List(ranked.Count);
for (var i = 0; i < ranked.Count; i++)
{
var recording = ranked[i];
// Signed so the proxy only serves recordings this plugin picked (#2).
var signature = ProxySignature.Create(eventGuid, recording.Folder, recording.Language);
var proxyUrl = $"{serverUrl}/api/ChaosflixStream/proxy/{eventGuid}"
+ $"?recordingFolder={Uri.EscapeDataString(recording.Folder)}"
+ $"&language={Uri.EscapeDataString(recording.Language)}"
+ $"&{ProxySignature.QueryParameter}={signature}";
// Probe the actual file to discover the real stream layout.
// CCC MP4s vary: some have 2 streams (video+audio), some have 3
// (video+video[visual impaired]+audio). We must declare the correct
// indices so the server generates proper ffmpeg -map flags.
// Keyed by the recording that is actually probed, not by the event: which
// recording wins depends on the configuration, and a configuration change
// must not hand out the stream layout of the previously chosen file (#3).
var cacheKey = $"{eventGuid}|{recording.Folder}|{recording.Language}";
// Only the preferred recording is probed, and only it declares a
// stream layout. Two reasons: probing every recording of every talk
// someone opens would multiply the upstream reads by the number of
// versions, and Jellyfin re-sorts the sources by the width of their
// first video stream (MediaSourceManager.SortMediaSources), so a
// version that declared its layout would overtake the preferred one
// whenever it is the larger file. Sources without streams keep the
// order they are handed over in, and ffmpeg maps their streams
// itself when one of them is played.
var mediaStreams = i == 0
? await ProbeMediaStreamsAsync(proxyUrl, cacheKey, cancellationToken).ConfigureAwait(false)
: new List();
var audioStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Audio);
// Subtitles are files of their own, so every version can carry them. They
// add no video stream, which is what Jellyfin re-sorts the sources by, so
// declaring them on the alternates leaves the order from #69 intact.
mediaStreams = mediaStreams
.Concat(SubtitleStreams(subtitles, eventGuid, serverUrl, mediaStreams.Count))
.ToList();
sources.Add(new MediaSourceInfo
{
// The first source keeps the item id: that is the id the DTO
// carries and jellyfin-androidtv echoes back (#55).
Id = (i == 0
? itemId ?? DeterministicGuid($"{recording.RecordingUrl}")
: DeterministicGuid($"{recording.RecordingUrl}|{recording.Folder}|{recording.Language}"))
.ToString("N"),
Name = names[i],
Path = proxyUrl,
Protocol = MediaProtocol.Http,
Container = DetectContainer(recording),
Size = (long)(recording.Size ?? 0) * 1024 * 1024,
RunTimeTicks = (long)(recording.Length ?? 0) * TimeSpan.TicksPerSecond,
Bitrate = recording.Length > 0 ? (int)((long)(recording.Size ?? 0) * 1024 * 1024 * 8 / recording.Length.Value) : null,
VideoType = VideoType.VideoFile,
DefaultAudioStreamIndex = audioStream?.Index,
IsRemote = false,
ReadAtNativeFramerate = false,
SupportsProbing = false,
SupportsDirectPlay = false,
SupportsDirectStream = true,
SupportsTranscoding = true,
MediaStreams = mediaStreams
});
}
return sources;
}
///
/// Picks the subtitle recordings of a talk that actually resolve to a file.
///
internal static List SelectSubtitleRecordings(List recordings)
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
var selected = new List();
foreach (var recording in recordings)
{
if (!SubtitleCodecs.ContainsKey(recording.MimeType)
|| string.IsNullOrEmpty(recording.RecordingUrl))
{
continue;
}
// "todo" is the c3subtitles queue: the API lists the recording as soon as
// a talk is scheduled for subtitling, months before the file exists, and
// the CDN answers 404 until it does.
if (recording.State.Equals("todo", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// A filename that is nothing but an extension (".en.srt") is a leftover of
// the same pipeline and 404s as well.
if (string.IsNullOrEmpty(recording.Filename) || recording.Filename.StartsWith('.'))
{
continue;
}
if (seen.Add($"{recording.Language}|{recording.MimeType}"))
{
selected.Add(recording);
}
}
return selected;
}
private static IEnumerable SubtitleStreams(
List subtitles, string eventGuid, string serverUrl, int firstIndex)
{
for (var i = 0; i < subtitles.Count; i++)
{
var recording = subtitles[i];
var signature = ProxySignature.Create(eventGuid, null, null, recording.Filename);
var url = $"{serverUrl}/api/ChaosflixStream/proxy/{eventGuid}/{signature}/"
+ Uri.EscapeDataString(recording.Filename);
yield return new MediaStream
{
Index = firstIndex + i,
Type = MediaStreamType.Subtitle,
Codec = SubtitleCodecs[recording.MimeType],
Language = recording.Language,
Title = FormatLanguage(recording.Language),
IsExternal = true,
SupportsExternalStream = true,
DeliveryMethod = SubtitleDeliveryMethod.External,
DeliveryUrl = url,
IsExternalUrl = true,
Path = url
};
}
}
// Jellyfin caches one media source list per talk and hands it to every
// client that asks within the next five minutes, so the order cannot depend
// on who is asking — a per-client order would be the order of whichever
// client asked first. The H.264 MP4 is the one recording no client has to
// re-encode: jellyfin-android offers h264 as its only transcoding video
// codec, and direct play is off for channel items (#81).
private static void PromoteCompatibleDefault(List ranked)
{
var index = ranked.FindIndex(r => !IsAv1(r) && DetectContainer(r) == "mp4");
if (index <= 0)
{
return;
}
var compatible = ranked[index];
ranked.RemoveAt(index);
ranked.Insert(0, compatible);
}
private static List RankRecordings(List videoRecordings, PluginConfiguration config)
{
var preferredMime = config.PreferredFormat == VideoFormat.WebM ? "video/webm" : "video/mp4";
return videoRecordings
.OrderByDescending(r =>
{
if (IsAv1(r)) return -1;
return r.MimeType.StartsWith(preferredMime, StringComparison.OrdinalIgnoreCase) ? 1 : 0;
})
.ThenByDescending(r =>
(config.PreferredQuality == VideoQuality.High && r.HighQuality) ||
(config.PreferredQuality == VideoQuality.Standard && !r.HighQuality) ? 1 : 0)
.ThenByDescending(r =>
{
if (string.IsNullOrEmpty(config.PreferredLanguage))
{
return 0;
}
return r.Language.Contains(config.PreferredLanguage, StringComparison.OrdinalIgnoreCase) ? 1 : 0;
})
.ThenByDescending(r => r.Width ?? 0)
.ToList();
}
private async Task> ProbeMediaStreamsAsync(
string proxyUrl, string cacheKey, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_probeCache.TryGetValue(cacheKey, out var cached) && cached.ExpiresAt > now)
{
Interlocked.Increment(ref _probeHits);
return cached.Streams;
}
Interlocked.Increment(ref _probeMisses);
try
{
_logger.LogInformation("Probing media streams for {CacheKey} via {Url}", cacheKey, proxyUrl);
var request = new MediaInfoRequest
{
MediaSource = new MediaSourceInfo
{
Path = proxyUrl,
Protocol = MediaProtocol.Http
},
MediaType = DlnaProfileType.Video
};
var info = await _mediaEncoder.GetMediaInfo(request, cancellationToken).ConfigureAwait(false);
var streams = info.MediaStreams?.ToList() ?? new List();
_logger.LogInformation("Probed {Count} streams for {CacheKey}: {Types}",
streams.Count, cacheKey,
string.Join(", ", streams.Select(s => $"{s.Type}@{s.Index}")));
_probeCache[cacheKey] = new ProbeCacheEntry(streams, _timeProvider.GetUtcNow().Add(ProbeCacheTtl));
EvictProbeCache();
return streams;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to probe {CacheKey}, returning empty streams", cacheKey);
return new List();
}
}
private void EvictProbeCache()
{
var now = _timeProvider.GetUtcNow();
foreach (var entry in _probeCache)
{
if (entry.Value.ExpiresAt <= now)
{
_probeCache.TryRemove(entry.Key, out _);
}
}
// All entries share one TTL, so the earliest expiry is the oldest entry.
while (_probeCache.Count > ProbeCacheCapacity)
{
var oldest = _probeCache.OrderBy(e => e.Value.ExpiresAt).Select(e => e.Key).FirstOrDefault();
if (oldest is null || !_probeCache.TryRemove(oldest, out _))
{
break;
}
}
}
///
/// Counts what the probe cache holds, for the configuration page.
///
internal ProbeCacheStats GetProbeCacheStats()
{
var now = _timeProvider.GetUtcNow();
return new ProbeCacheStats(
_probeCache.Count(e => e.Value.ExpiresAt > now),
ProbeCacheCapacity,
Interlocked.Read(ref _probeHits),
Interlocked.Read(ref _probeMisses),
_servedItemIds.Count);
}
///
/// Forgets every probed stream layout, so the next playback probes the file again.
///
internal void ClearProbeCache() => _probeCache.Clear();
/// What the probe cache holds right now.
internal sealed record ProbeCacheStats(int Entries, int Capacity, long Hits, long Misses, int ServedItems);
///
/// Drops the media sources Jellyfin cached for every talk this channel has
/// served, so the next request is answered with the current configuration.
///
internal void InvalidateCachedMediaSources()
{
var count = 0;
foreach (var id in _servedItemIds.Keys)
{
_servedItemIds.TryRemove(id, out _);
_mediaSourceCache?.Remove(id);
count++;
}
_logger.LogInformation("Configuration changed, dropped {Count} cached media sources", count);
}
// The channel is constructed by DI, which may happen before the plugin
// instance exists; subscribing on first use is both late enough to find it
// and early enough, because nothing is cached before this call.
private void SubscribeToConfigurationChanges()
{
var plugin = Plugin.Instance;
if (plugin is null || ReferenceEquals(plugin, _subscribedPlugin))
{
return;
}
lock (_subscriptionLock)
{
if (ReferenceEquals(plugin, _subscribedPlugin))
{
return;
}
if (_subscribedPlugin is not null)
{
_subscribedPlugin.ConfigurationChanged -= OnConfigurationChanged;
}
plugin.ConfigurationChanged += OnConfigurationChanged;
_subscribedPlugin = plugin;
}
}
private void OnConfigurationChanged(object? sender, BasePluginConfiguration configuration)
=> InvalidateCachedMediaSources();
private void RememberServedId(string id)
{
var now = _timeProvider.GetUtcNow();
_servedItemIds[id] = now.Add(ServedIdRetention);
foreach (var entry in _servedItemIds)
{
if (entry.Value <= now)
{
_servedItemIds.TryRemove(entry.Key, out _);
}
}
}
private static bool IsAv1(CccRecording r) =>
r.MimeType.Contains("av01", StringComparison.OrdinalIgnoreCase) ||
r.Folder.StartsWith("av1", StringComparison.OrdinalIgnoreCase);
private static string DetectContainer(CccRecording r) =>
r.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase) ? "mp4" : "webm";
///
/// Names the sources of one talk, e.g. "HD MP4 · Deutsch". Versions that
/// would share a name — the same format and language in two resolutions —
/// are told apart by their resolution.
///
private static List SourceNames(IReadOnlyList ranked)
{
var names = ranked.Select(FormatRecordingName).ToList();
return names
.Select((name, i) => names.Count(other => string.Equals(other, name, StringComparison.Ordinal)) > 1
? $"{name} ({FormatResolution(ranked[i])})"
: name)
.ToList();
}
private static string FormatRecordingName(CccRecording r)
{
var quality = r.HighQuality ? "HD" : "SD";
var format = IsAv1(r) ? "AV1" : DetectContainer(r) == "mp4" ? "MP4" : "WebM";
return $"{quality} {format} · {FormatLanguage(r.Language)}";
}
private static string FormatResolution(CccRecording r) =>
r.Height > 0 ? $"{r.Width}x{r.Height}" : $"{r.Width ?? 0}p";
///
/// Turns a CCC language tag into what a viewer picking a version reads.
/// The tag is an ISO 639-2 code, or several joined by "-" when the
/// recording carries a translation next to the original.
///
private static string FormatLanguage(string language)
{
if (string.IsNullOrWhiteSpace(language))
{
return "Original";
}
var parts = language
.Split('-', StringSplitOptions.RemoveEmptyEntries)
.Select(part => LanguageNames.TryGetValue(part, out var name) ? name : part.ToUpperInvariant());
return string.Join(" + ", parts);
}
[GeneratedRegex(@"^\d{4}c\d$|^\d{4}$")]
private static partial Regex YearPattern();
///
/// Looks up the Jellyfin item id behind a channel item's external id.
///
///
/// The DTO of a channel item carries a placeholder media source whose id is the
/// item id, because Jellyfin only resolves the real sources in PlaybackInfo and
/// drops the placeholder there. jellyfin-androidtv takes that placeholder id from
/// the DTO and sends it back as MediaSourceId, so a source id of our own making
/// matches nothing, PlaybackInfo answers NoCompatibleStream and the app spins
/// forever without reporting anything (#55). Handing out the item id is also what
/// an ordinary library item does.
///
private Guid? ResolveItemId(string externalId)
{
if (_libraryManager is null)
{
return null;
}
try
{
var ids = _libraryManager.GetItemIds(new InternalItemsQuery { ExternalId = externalId, Limit = 1 });
return ids.Count > 0 ? ids[0] : null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not resolve the item id for {ExternalId}", externalId);
return null;
}
}
///
/// Creates a deterministic GUID from a string (MD5-based, namespace v3 style).
/// Jellyfin's streaming pipeline requires MediaSourceInfo.Id to be GUID-parseable.
///
private static Guid DeterministicGuid(string input)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
return new Guid(hash);
}
private sealed record ProbeCacheEntry(List Streams, DateTimeOffset ExpiresAt);
}