v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs
Some checks failed
release / build-release (push) Has been cancelled

- per-creator "All videos" folder (release order across sub-channels); sub-channel copies use
  cpost: ids and UserDataMirror keeps played/resume state in sync
- SortNameUpdater forces release-order sort names so every client lists newest first by default
- official Floatplane icon as channel image (Primary only — a channel Thumb hid video thumbnails
  in Continue Watching) and as 16:9 plugin thumbnail; one-time image replacement on upgrade
- OAuth device-flow login (FloatplaneAuthService + settings UI); needs a Floatplane client id;
  Turnstile verified domain-bound so password login inside Jellyfin is impossible
- generic install.sh (linuxserver / official image / native), thumb.png in package + manifest
- README rewritten for a general audience, doc/usage.md with screenshots, 60 tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 16:36:32 +01:00
parent 2a883c02de
commit bf476134c2
33 changed files with 1478 additions and 155 deletions

View File

@@ -32,18 +32,22 @@ namespace Jellyfin.Plugin.Floatplane.Channel;
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<FloatplaneChannel> _logger;
private readonly Func<PluginConfiguration> _config;
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger)
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, SortNameUpdater sortNames, ILogger<FloatplaneChannel> logger)
{
_catalog = catalog;
_appHost = appHost;
_sortNames = sortNames;
_logger = logger;
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
}
@@ -82,7 +86,9 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
};
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb];
// 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<ImageType> GetSupportedChannelImages() => [ImageType.Primary];
/// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
@@ -111,6 +117,10 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
{
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..];
@@ -125,6 +135,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
items = new List<ChannelItemInfo>();
}
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)
@@ -164,12 +180,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
/// <inheritdoc />
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
{
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal))
var postId = PostIdOf(id);
if (postId is null)
{
return Array.Empty<MediaSourceInfo>();
}
var postId = id[PostPrefix.Length..];
PostModel post;
try
{
@@ -259,10 +275,23 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
if (creator.Channels.Count == 0)
{
return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false);
return await GetAllVideoItemsAsync(creator.Id, ct).ConfigureAwait(false);
}
return creator.Channels.Select(ch => new ChannelItemInfo
var items = new List<ChannelItemInfo>
{
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,
@@ -271,25 +300,62 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
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();
}));
return items;
}
/// <summary>"All videos": every video post of the creator, newest first (canonical <c>post:</c> ids).</summary>
private async Task<List<ChannelItemInfo>> 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();
}
/// <summary>Sub-channel folder: the same posts under <c>cpost:</c> ids (one parent per item in Jellyfin).</summary>
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();
return posts.Select(p => ToItem(p, creator, markLocked, ChannelPostPrefix + channelId + ":")).ToList();
}
/// <summary>
/// Extracts the Floatplane post id from a channel item id (<c>post:&lt;id&gt;</c> or
/// <c>cpost:&lt;channelId&gt;:&lt;id&gt;</c>); null for folders / foreign ids.
/// </summary>
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;
}
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true)
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 = PostPrefix + post.Id,
Id = idPrefix + post.Id,
Name = name,
Type = ChannelItemType.Media,
MediaType = ChannelMediaType.Video,