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); }