using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Floatplane.Api; using Jellyfin.Plugin.Floatplane.Api.Models; using Jellyfin.Plugin.Floatplane.Configuration; using Jellyfin.Plugin.Floatplane.Playback; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Channels; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Floatplane.Channel; /// /// The "Floatplane" channel: root → creators → sub-channels → video posts. /// Item ids are stable strings (creator:…, channel:…, post:…); a post /// only ever appears under its own channel, as Jellyfin allows one parent per item. /// public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia { private const string CreatorPrefix = "creator:"; private const string AllPrefix = "all:"; private const string ChannelPrefix = "channel:"; private const string PostPrefix = "post:"; private const string ChannelPostPrefix = "cpost:"; private readonly CatalogService _catalog; private readonly IServerApplicationHost _appHost; private readonly SortNameUpdater _sortNames; private readonly ILogger _logger; private readonly Func _config; public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, SortNameUpdater sortNames, ILogger logger) { _catalog = catalog; _appHost = appHost; _sortNames = sortNames; _logger = logger; _config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration(); } /// public string Name => "Floatplane"; // never rename: the channel's library id derives from it /// public string Description => "Your Floatplane subscriptions"; /// public string DataVersion => "1"; /// public string HomePageUrl => "https://www.floatplane.com"; /// public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; /// public bool IsEnabledFor(string userId) => true; /// public string? GetCacheKey(string? userId) => "g" + _catalog.Generation.ToString(CultureInfo.InvariantCulture); /// public InternalChannelFeatures GetChannelFeatures() => new() { MediaTypes = [ChannelMediaType.Video], ContentTypes = [ChannelMediaContentType.Clip], DefaultSortFields = [ChannelItemSortField.PremiereDate, ChannelItemSortField.Name, ChannelItemSortField.Runtime], SupportsSortOrderToggle = true, MaxPageSize = 100, AutoRefreshLevels = 3, SupportsContentDownloading = false }; /// // Primary only: a channel Thumb would be inherited by every video card in "Continue // watching" (the web client prefers parent thumbs there), hiding the video thumbnails. public IEnumerable GetSupportedChannelImages() => [ImageType.Primary]; /// public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) { var stream = typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Floatplane.Resources.logo.png"); return Task.FromResult(new DynamicImageResponse { HasImage = stream != null, Stream = stream, Format = ImageFormat.Png }); } /// public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) { var folderId = query.FolderId ?? string.Empty; _logger.LogDebug("GetChannelItems folder='{Folder}' start={Start} limit={Limit}", folderId, query.StartIndex, query.Limit); List items; if (string.IsNullOrEmpty(folderId)) { items = await GetRootItemsAsync(cancellationToken).ConfigureAwait(false); } else if (folderId.StartsWith(CreatorPrefix, StringComparison.Ordinal)) { items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false); } else if (folderId.StartsWith(AllPrefix, StringComparison.Ordinal)) { items = await GetAllVideoItemsAsync(folderId[AllPrefix.Length..], cancellationToken).ConfigureAwait(false); } else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal)) { var rest = folderId[ChannelPrefix.Length..]; var sep = rest.IndexOf(':', StringComparison.Ordinal); var creatorId = sep > 0 ? rest[..sep] : rest; var channelId = sep > 0 ? rest[(sep + 1)..] : string.Empty; items = await GetPostItemsAsync(creatorId, channelId, cancellationToken).ConfigureAwait(false); } else { _logger.LogWarning("Unknown folder id {Folder}", folderId); items = new List(); } if (items.Any(i => i.Type == ChannelItemType.Media)) { // Jellyfin creates the DB entities after this returns; fix their sort names then. _sortNames.ScheduleSoon(); } items = ApplySort(items, query); var total = items.Count; if (query.StartIndex.HasValue && query.StartIndex.Value > 0) { items = items.Skip(query.StartIndex.Value).ToList(); } if (query.Limit.HasValue && query.Limit.Value > 0 && items.Count > query.Limit.Value) { items = items.Take(query.Limit.Value).ToList(); } return new ChannelItemResult { Items = items, TotalRecordCount = total }; } /// public async Task> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken) { var creators = await _catalog.GetCreatorsAsync(false, cancellationToken).ConfigureAwait(false); var latest = new List(); foreach (var creator in creators) { try { var posts = await _catalog.FetchPostsAsync(creator.Id, string.Empty, 20, cancellationToken).ConfigureAwait(false); latest.AddRange(posts.Select(p => ToItem(p, creator, _catalog.HasSession))); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Latest media for {Creator} failed", creator.Urlname); } } return latest.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).Take(40).ToList(); } /// public async Task> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken) { var postId = PostIdOf(id); if (postId is null) { return Array.Empty(); } PostModel post; try { post = await _catalog.GetPostDetailsAsync(postId, cancellationToken).ConfigureAwait(false); } catch (FloatplaneAuthException ex) { _logger.LogWarning("Cannot play Floatplane post {Post}: {Message}. Paste a valid sails.sid cookie in the plugin settings.", postId, ex.Message); return Array.Empty(); } var attachments = OrderedVideos(post); if (attachments.Count == 0) { _logger.LogWarning("Post {Post} has no playable video attachment", postId); return Array.Empty(); } var cfg = _config(); var baseUrl = ProxyBaseUrl(); var sources = new List(); var first = true; foreach (var video in attachments) { var levels = video.Levels .Where(l => l.Height > 0 && l.Height <= Math.Max(360, cfg.MaxHeight)) .OrderByDescending(l => Math.Abs(l.Height - DeliveryVariant.ParseHeight(cfg.PreferredQuality)) == 0 ? 1 : 0) .ThenByDescending(l => l.Height) .ToList(); if (levels.Count == 0) { levels = video.Levels.OrderByDescending(l => l.Height).Take(1).ToList(); } if (levels.Count == 0) { // No level info (still processing?) — still try the preferred quality. levels = [new VideoLevel { Name = cfg.PreferredQuality, Height = DeliveryVariant.ParseHeight(cfg.PreferredQuality), Label = cfg.PreferredQuality + "p" }]; } var titlePrefix = attachments.Count > 1 ? (string.IsNullOrWhiteSpace(video.Title) ? "Video" : video.Title) + " · " : string.Empty; foreach (var level in levels) { var signature = ProxySignature.Create("hls", postId, video.Id, level.Name); var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/index.m3u8?{ProxySignature.QueryParameter}={signature}"; sources.Add(BuildSource(id, first, titlePrefix + (level.Label ?? level.Name + "p"), path, "ts", video, level, hls: true)); first = false; } if (cfg.EnableMp4Sources) { var level = levels[0]; var signature = ProxySignature.Create("mp4", postId, video.Id, level.Name); var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/video.mp4?{ProxySignature.QueryParameter}={signature}"; sources.Add(BuildSource(id, false, titlePrefix + (level.Label ?? level.Name + "p") + " (MP4)", path, "mp4", video, level, hls: false)); } } return sources; } // ───────────────────────────── folders ───────────────────────────── private async Task> GetRootItemsAsync(CancellationToken ct) { var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false); return creators.Select(c => new ChannelItemInfo { Id = CreatorPrefix + c.Id, Name = c.Title, Type = ChannelItemType.Folder, FolderType = ChannelFolderType.Container, Overview = c.Description, ImageUrl = c.Icon?.BestPath(600) ?? c.Card?.BestPath(1200) ?? c.Cover?.BestPath(1200), HomePageUrl = "https://www.floatplane.com/channel/" + c.Urlname + "/home" }).ToList(); } private async Task> GetCreatorItemsAsync(string creatorId, CancellationToken ct) { var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false); var creator = creators.FirstOrDefault(c => c.Id == creatorId); if (creator == null) { return new List(); } if (creator.Channels.Count == 0) { return await GetAllVideoItemsAsync(creator.Id, ct).ConfigureAwait(false); } var items = new List { new() { Id = AllPrefix + creator.Id, Name = "All videos", Type = ChannelItemType.Folder, FolderType = ChannelFolderType.Container, Overview = "Every video from " + creator.Title + " across all its channels, newest first.", ImageUrl = creator.Card?.BestPath(1200) ?? creator.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600), HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home" } }; items.AddRange(creator.Channels.Select(ch => new ChannelItemInfo { Id = ChannelPrefix + creator.Id + ":" + ch.Id, Name = ch.Title, Type = ChannelItemType.Folder, FolderType = ChannelFolderType.Container, Overview = ch.About, ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600), HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname })); return items; } /// "All videos": every video post of the creator, newest first (canonical post: ids). private async Task> GetAllVideoItemsAsync(string creatorId, CancellationToken ct) { var creator = _catalog.FindCreator(creatorId); var posts = await _catalog.GetAllPostsAsync(creatorId, ct).ConfigureAwait(false); var markLocked = _catalog.HasSession; return posts.Select(p => ToItem(p, creator, markLocked)).ToList(); } /// Sub-channel folder: the same posts under cpost: ids (one parent per item in Jellyfin). private async Task> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct) { var creator = _catalog.FindCreator(creatorId); var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false); // Anonymous listings flag everything as inaccessible; only mark locks when logged in. var markLocked = _catalog.HasSession; return posts.Select(p => ToItem(p, creator, markLocked, ChannelPostPrefix + channelId + ":")).ToList(); } /// /// Extracts the Floatplane post id from a channel item id (post:<id> or /// cpost:<channelId>:<id>); null for folders / foreign ids. /// internal static string? PostIdOf(string? externalId) { if (string.IsNullOrEmpty(externalId)) { return null; } if (externalId.StartsWith(PostPrefix, StringComparison.Ordinal)) { return externalId[PostPrefix.Length..]; } if (externalId.StartsWith(ChannelPostPrefix, StringComparison.Ordinal)) { var rest = externalId[ChannelPostPrefix.Length..]; var sep = rest.IndexOf(':', StringComparison.Ordinal); return sep > 0 && sep < rest.Length - 1 ? rest[(sep + 1)..] : null; } return null; } /// Maps a post to a channel item. Internal for tests. internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true, string idPrefix = PostPrefix) { var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title; var item = new ChannelItemInfo { Id = idPrefix + post.Id, Name = name, Type = ChannelItemType.Media, MediaType = ChannelMediaType.Video, ContentType = ChannelMediaContentType.Clip, Overview = Overview(post), ImageUrl = post.Thumbnail?.BestPath(1200), PremiereDate = post.ReleaseDate?.UtcDateTime, DateCreated = post.ReleaseDate?.UtcDateTime, ProductionYear = post.ReleaseDate?.Year, RunTimeTicks = post.DurationSeconds > 0 ? (long)(post.DurationSeconds * TimeSpan.TicksPerSecond) : null, HomePageUrl = "https://www.floatplane.com/post/" + post.Id, Tags = post.Tags.ToList(), Studios = creator != null ? [creator.Title] : [], Genres = post.Channel is { IsReferenceOnly: false, Title.Length: > 0 } ? [post.Channel.Title] : [], ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Floatplane"] = post.Id } }; if (post.Score != 0) { item.CommunityRating = (float)Math.Clamp(5 + Math.Log10(Math.Max(1, post.Score)) * 1.5, 0, 10); } return item; } private static string? Overview(PostModel post) { var text = post.TextMarkdown; if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(post.Text)) { text = StripHtml(post.Text); } if (string.IsNullOrWhiteSpace(text)) { return null; } text = text.Replace("\\-", "-", StringComparison.Ordinal).Replace("\\_", "_", StringComparison.Ordinal).Trim(); return text.Length > 2000 ? text[..2000] + "…" : text; } [GeneratedRegex("|

||", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex BlockBreaks(); [GeneratedRegex("<[^>]+>", RegexOptions.CultureInvariant)] private static partial Regex AnyTag(); internal static string StripHtml(string html) { var text = BlockBreaks().Replace(html, "\n"); text = AnyTag().Replace(text, string.Empty); text = System.Net.WebUtility.HtmlDecode(text); return Regex.Replace(text, "\n{3,}", "\n\n").Trim(); } private static List ApplySort(List items, InternalChannelItemQuery query) { IEnumerable sorted = query.SortBy switch { ChannelItemSortField.Name => items.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase), ChannelItemSortField.Runtime => items.OrderBy(i => i.RunTimeTicks ?? 0), ChannelItemSortField.PremiereDate or ChannelItemSortField.DateCreated => items.OrderBy(i => i.PremiereDate ?? DateTime.MinValue), _ => null! }; if (sorted == null) { // default: folders alphabetical as given, media newest first return items.All(i => i.Type == ChannelItemType.Folder) ? items : items.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).ToList(); } return (query.SortDescending ? sorted.Reverse() : sorted).ToList(); } // ───────────────────────────── media sources ───────────────────────────── private static List OrderedVideos(PostModel post) { var videos = (post.VideoAttachments ?? new List()) .Where(v => !v.IsReferenceOnly && !v.IsProcessing) .ToList(); if (post.AttachmentOrder.Count > 0) { videos = videos.OrderBy(v => { var i = post.AttachmentOrder.IndexOf(v.Id); return i < 0 ? int.MaxValue : i; }).ToList(); } return videos; } private string ProxyBaseUrl() { var configured = _config().ProxyBaseUrl?.Trim().TrimEnd('/'); if (!string.IsNullOrEmpty(configured)) { return configured; } return _appHost.GetApiUrlForLocalAccess(null, false).TrimEnd('/'); } private static MediaSourceInfo BuildSource(string itemId, bool isPrimary, string name, string path, string container, VideoModel video, VideoLevel level, bool hls) { var width = level.Width > 0 ? level.Width : (int)Math.Round(level.Height * 16.0 / 9.0); var streams = new List { new() { Type = MediaStreamType.Video, Index = 0, Codec = "h264", Profile = "High", Level = level.Height >= 2160 ? 51 : level.Height >= 1080 ? 40 : 31, Width = width, Height = level.Height, IsDefault = true, IsInterlaced = false, AverageFrameRate = 30, RealFrameRate = 30, PixelFormat = "yuv420p", BitRate = EstimateBitrate(level.Height) }, new() { Type = MediaStreamType.Audio, Index = 1, Codec = "aac", Profile = "LC", Channels = 2, SampleRate = 48000, BitRate = 160000, IsDefault = true, Language = "eng" } }; return new MediaSourceInfo { // The first source keeps the item id (what clients echo back as mediaSourceId). Id = isPrimary ? DeterministicGuid(itemId).ToString("N") : DeterministicGuid($"{itemId}|{video.Id}|{level.Name}|{container}").ToString("N"), Name = name, Path = path, Protocol = MediaProtocol.Http, Container = container, VideoType = VideoType.VideoFile, IsRemote = false, IsInfiniteStream = false, RunTimeTicks = video.Duration > 0 ? (long)(video.Duration * TimeSpan.TicksPerSecond) : null, Bitrate = EstimateBitrate(level.Height) + 160000, SupportsProbing = false, SupportsDirectPlay = false, SupportsDirectStream = !hls, SupportsTranscoding = true, RequiresOpening = false, RequiresClosing = false, ReadAtNativeFramerate = false, DefaultAudioStreamIndex = 1, MediaStreams = streams }; } private static int EstimateBitrate(int height) => height switch { >= 2160 => 8_000_000, >= 1080 => 2_500_000, >= 720 => 1_200_000, >= 480 => 600_000, _ => 350_000 }; internal static Guid DeterministicGuid(string input) { var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); return new Guid(hash); } }