Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Floatplane.Api;
using Jellyfin.Plugin.Floatplane.Api.Models;
using Jellyfin.Plugin.Floatplane.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Floatplane.Channel;
/// <summary>
/// In-memory catalogue: which creators to show, their sub-channels, and the newest posts
/// per channel. Everything here is cheap public listing data; post details and delivery
/// (login required) live in <see cref="Playback.DeliveryResolver"/> and the channel.
/// </summary>
public class CatalogService
{
private readonly FloatplaneApiClient _api;
private readonly ILogger<CatalogService> _logger;
private readonly Func<PluginConfiguration> _config;
private readonly SemaphoreSlim _creatorLock = new(1, 1);
private readonly ConcurrentDictionary<string, CachedPosts> _posts = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CachedPost> _postDetails = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _postLocks = new(StringComparer.Ordinal);
private IReadOnlyList<CreatorModel> _creators = Array.Empty<CreatorModel>();
private DateTimeOffset _creatorsFetched = DateTimeOffset.MinValue;
private int _generation;
public CatalogService(FloatplaneApiClient api, ILogger<CatalogService> logger)
: this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
{
}
public CatalogService(FloatplaneApiClient api, ILogger<CatalogService> logger, Func<PluginConfiguration> config)
{
_api = api;
_logger = logger;
_config = config;
}
/// <summary>True when a session cookie is configured.</summary>
public bool HasSession => _api.HasSession;
/// <summary>Bumped on every refresh so Jellyfin's 3-hour listing cache is bypassed.</summary>
public int Generation => _generation;
public DateTimeOffset LastRefresh => _creatorsFetched;
/// <summary>Session state as last observed (null = not checked yet).</summary>
public string? SessionState { get; private set; }
public string? LastError { get; private set; }
/// <summary>Cached creators (may be empty before the first refresh).</summary>
public IReadOnlyList<CreatorModel> Creators => _creators;
public int CachedPostListCount => _posts.Count;
/// <summary>Invalidates every cache and bumps the generation.</summary>
public void Invalidate()
{
_posts.Clear();
_postDetails.Clear();
_creatorsFetched = DateTimeOffset.MinValue;
Interlocked.Increment(ref _generation);
}
/// <summary>Full refresh: creators + channels. Post lists are refetched lazily on browse.</summary>
public async Task RefreshAsync(IProgress<double>? progress, CancellationToken ct)
{
_posts.Clear();
_postDetails.Clear();
await GetCreatorsAsync(true, ct).ConfigureAwait(false);
progress?.Report(50);
// Warm the first page of every channel so the first browse is instant.
var creators = _creators;
var total = Math.Max(1, creators.Sum(c => Math.Max(1, c.Channels.Count)));
var done = 0;
foreach (var creator in creators)
{
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { string.Empty };
foreach (var channelId in channelIds)
{
ct.ThrowIfCancellationRequested();
try
{
await GetPostsAsync(creator.Id, channelId, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Could not warm posts for {Creator}/{Channel}", creator.Urlname, channelId);
}
done++;
progress?.Report(50 + (50.0 * done / total));
}
}
Interlocked.Increment(ref _generation);
}
/// <summary>
/// Creators to show: the account's subscriptions (when a valid session exists) plus the
/// configured extra url-names. Public data is used wherever possible so browsing works
/// without a session.
/// </summary>
public async Task<IReadOnlyList<CreatorModel>> GetCreatorsAsync(bool force, CancellationToken ct)
{
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl)
{
return _creators;
}
await _creatorLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl)
{
return _creators;
}
var result = new Dictionary<string, CreatorModel>(StringComparer.Ordinal);
var cfg = _config();
if (_api.HasSession)
{
try
{
var subs = await _api.GetSubscriptionsAsync(ct).ConfigureAwait(false);
SessionState = "ok";
LastError = null;
foreach (var creatorId in subs.Select(s => s.Creator).Where(id => !string.IsNullOrEmpty(id)).Distinct(StringComparer.Ordinal))
{
try
{
var creator = await _api.GetCreatorAsync(creatorId, ct).ConfigureAwait(false);
result[creator.Id] = creator;
}
catch (FloatplaneApiException ex)
{
_logger.LogWarning(ex, "Could not load subscribed creator {Id}", creatorId);
}
}
}
catch (FloatplaneAuthException ex)
{
SessionState = "invalid";
LastError = ex.Message;
_logger.LogWarning("Floatplane session cookie rejected ({Message}); paste a fresh sails.sid in the plugin settings", ex.Message);
}
}
else
{
SessionState = "none";
}
var extra = (cfg.ExtraCreators ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(n => n.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (extra.Count > 0)
{
try
{
var named = await _api.GetCreatorsByUrlNameAsync(extra, ct).ConfigureAwait(false);
foreach (var creator in named)
{
result.TryAdd(creator.Id, creator);
}
}
catch (FloatplaneApiException ex)
{
LastError = ex.Message;
_logger.LogWarning(ex, "Could not resolve extra creators {Names}", string.Join(",", extra));
}
}
// creator/named and creator/info both include channels; fill in if missing.
var missing = result.Values.Where(c => c.Channels.Count == 0).Select(c => c.Id).ToList();
if (missing.Count > 0)
{
try
{
var channels = await _api.GetCreatorChannelsAsync(missing, ct).ConfigureAwait(false);
foreach (var ch in channels)
{
if (ch.Creator != null && result.TryGetValue(ch.Creator, out var creator))
{
creator.Channels.Add(ch);
}
}
}
catch (FloatplaneApiException ex)
{
_logger.LogDebug(ex, "channels/list failed");
}
}
foreach (var creator in result.Values)
{
creator.Channels = creator.Channels.OrderBy(c => c.Order).ThenBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList();
}
_creators = result.Values.OrderBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList();
_creatorsFetched = DateTimeOffset.UtcNow;
_logger.LogInformation("Floatplane catalogue: {Count} creators ({Session})", _creators.Count, SessionState);
return _creators;
}
finally
{
_creatorLock.Release();
}
}
public CreatorModel? FindCreator(string creatorId) => _creators.FirstOrDefault(c => string.Equals(c.Id, creatorId, StringComparison.Ordinal));
/// <summary>
/// Newest video posts of a channel (<paramref name="channelId"/> empty = whole creator),
/// up to <c>MaxPostsPerChannel</c>, cached for <c>CatalogCacheMinutes</c>.
/// </summary>
public async Task<IReadOnlyList<PostModel>> GetPostsAsync(string creatorId, string channelId, CancellationToken ct)
{
var key = creatorId + "|" + channelId;
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
if (_posts.TryGetValue(key, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < ttl)
{
return cached.Posts;
}
var max = Math.Clamp(_config().MaxPostsPerChannel, 20, 2000);
var posts = await FetchPostsAsync(creatorId, channelId, max, ct).ConfigureAwait(false);
_posts[key] = new CachedPosts(posts, DateTimeOffset.UtcNow);
return posts;
}
/// <summary>Pages through /content/creator until <paramref name="max"/> posts or the end.</summary>
public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null)
{
var list = new List<PostModel>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// Without a session Floatplane reports every post as inaccessible, so the
// "locked" filter only makes sense once we are logged in.
var showLocked = _config().ShowLockedPosts || !_api.HasSession;
for (var offset = 0; list.Count < max; offset += 20)
{
ct.ThrowIfCancellationRequested();
var page = await _api.ListPostsAsync(
new PostQuery
{
CreatorId = creatorId,
ChannelId = string.IsNullOrEmpty(channelId) ? null : channelId,
Limit = 20,
FetchAfter = offset,
HasVideo = true,
Search = search
},
ct).ConfigureAwait(false);
if (page.Count == 0)
{
break;
}
foreach (var post in page)
{
if (!post.HasVideo || !seen.Add(post.Id))
{
continue;
}
if (!post.IsAccessible && !showLocked)
{
continue;
}
list.Add(post);
if (list.Count >= max)
{
break;
}
}
if (page.Count < 20)
{
break;
}
}
return list;
}
/// <summary>Looks a post up in the cached lists (no network).</summary>
public PostModel? FindCachedPost(string postId)
{
foreach (var entry in _posts.Values)
{
var hit = entry.Posts.FirstOrDefault(p => string.Equals(p.Id, postId, StringComparison.Ordinal));
if (hit != null)
{
return hit;
}
}
return null;
}
/// <summary>Post with expanded video attachments (login required). Cached for one hour.</summary>
public async Task<PostModel> GetPostDetailsAsync(string postId, CancellationToken ct)
{
if (_postDetails.TryGetValue(postId, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1))
{
return cached.Post;
}
var gate = _postLocks.GetOrAdd(postId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_postDetails.TryGetValue(postId, out cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1))
{
return cached.Post;
}
var post = await _api.GetPostAsync(postId, ct).ConfigureAwait(false);
SessionState = "ok";
_postDetails[postId] = new CachedPost(post, DateTimeOffset.UtcNow);
return post;
}
catch (FloatplaneAuthException ex)
{
SessionState = ex.ErrorName == "notSubscribedError" ? "ok" : "invalid";
LastError = ex.Message;
throw;
}
finally
{
gate.Release();
}
}
private sealed record CachedPosts(IReadOnlyList<PostModel> Posts, DateTimeOffset Fetched);
private sealed record CachedPost(PostModel Post, DateTimeOffset Fetched);
}

View File

@@ -0,0 +1,472 @@
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;
/// <summary>
/// The "Floatplane" channel: root → creators → sub-channels → video posts.
/// Item ids are stable strings (<c>creator:…</c>, <c>channel:…</c>, <c>post:…</c>); a post
/// only ever appears under its own channel, as Jellyfin allows one parent per item.
/// </summary>
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
{
private const string CreatorPrefix = "creator:";
private const string ChannelPrefix = "channel:";
private const string PostPrefix = "post:";
private readonly CatalogService _catalog;
private readonly IServerApplicationHost _appHost;
private readonly ILogger<FloatplaneChannel> _logger;
private readonly Func<PluginConfiguration> _config;
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger)
{
_catalog = catalog;
_appHost = appHost;
_logger = logger;
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
}
/// <inheritdoc />
public string Name => "Floatplane"; // never rename: the channel's library id derives from it
/// <inheritdoc />
public string Description => "Your Floatplane subscriptions";
/// <inheritdoc />
public string DataVersion => "1";
/// <inheritdoc />
public string HomePageUrl => "https://www.floatplane.com";
/// <inheritdoc />
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
/// <inheritdoc />
public bool IsEnabledFor(string userId) => true;
/// <inheritdoc />
public string? GetCacheKey(string? userId) => "g" + _catalog.Generation.ToString(CultureInfo.InvariantCulture);
/// <inheritdoc />
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
};
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb];
/// <inheritdoc />
public Task<DynamicImageResponse> 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
});
}
/// <inheritdoc />
public async Task<ChannelItemResult> 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<ChannelItemInfo> 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(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<ChannelItemInfo>();
}
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 };
}
/// <inheritdoc />
public async Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
{
var creators = await _catalog.GetCreatorsAsync(false, cancellationToken).ConfigureAwait(false);
var latest = new List<ChannelItemInfo>();
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();
}
/// <inheritdoc />
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
{
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal))
{
return Array.Empty<MediaSourceInfo>();
}
var postId = id[PostPrefix.Length..];
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<MediaSourceInfo>();
}
var attachments = OrderedVideos(post);
if (attachments.Count == 0)
{
_logger.LogWarning("Post {Post} has no playable video attachment", postId);
return Array.Empty<MediaSourceInfo>();
}
var cfg = _config();
var baseUrl = ProxyBaseUrl();
var sources = new List<MediaSourceInfo>();
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<List<ChannelItemInfo>> 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<List<ChannelItemInfo>> 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<ChannelItemInfo>();
}
if (creator.Channels.Count == 0)
{
return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false);
}
return 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
}).ToList();
}
private async Task<List<ChannelItemInfo>> 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)).ToList();
}
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true)
{
var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
var item = new ChannelItemInfo
{
Id = PostPrefix + 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<string, string>(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("<br\\s*/?>|</p>|</div>|</li>", 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<ChannelItemInfo> ApplySort(List<ChannelItemInfo> items, InternalChannelItemQuery query)
{
IEnumerable<ChannelItemInfo> 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<VideoModel> OrderedVideos(PostModel post)
{
var videos = (post.VideoAttachments ?? new List<VideoModel>())
.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<MediaStream>
{
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);
}
}