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,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Floatplane.Api.Models;
/// <summary>Image with optional pre-scaled variants (<c>pbs.floatplane.com</c>, public).</summary>
public class ImageModel
{
public int Width { get; set; }
public int Height { get; set; }
public string Path { get; set; } = string.Empty;
public List<ImageModel> ChildImages { get; set; } = new();
/// <summary>Largest variant whose width does not exceed <paramref name="maxWidth"/>, else the original.</summary>
public string BestPath(int maxWidth = 1200)
{
var candidates = ChildImages.Where(c => c.Width <= maxWidth && !string.IsNullOrEmpty(c.Path)).OrderByDescending(c => c.Width).ToList();
if (candidates.Count > 0)
{
return candidates[0].Path;
}
return Path;
}
}
/// <summary>Error envelope Floatplane returns for 4xx responses.</summary>
public class ApiErrorModel
{
public string? Id { get; set; }
public List<ApiErrorItem> Errors { get; set; } = new();
public string? Message { get; set; }
public bool Has(string name) => Errors.Any(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase));
}
public class ApiErrorItem
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// Floatplane sometimes sends a related object inline and sometimes only its id
/// (e.g. <c>channel</c> on a post, <c>videoAttachments</c> in list responses).
/// This converter accepts both: a string becomes <c>{ Id = string }</c>.
/// </summary>
/// <typeparam name="T">Model type with an <c>Id</c> property.</typeparam>
public class IdOrObjectConverter<T> : JsonConverter<T>
where T : class, IHasId, new()
{
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.String)
{
return new T { Id = reader.GetString() ?? string.Empty, IsReferenceOnly = true };
}
// Avoid infinite recursion: deserialise with a copy of the options minus this converter.
var inner = new JsonSerializerOptions(options);
var self = inner.Converters.FirstOrDefault(c => c is IdOrObjectConverter<T>);
if (self != null)
{
inner.Converters.Remove(self);
}
return JsonSerializer.Deserialize<T>(ref reader, inner);
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
var inner = new JsonSerializerOptions(options);
var self = inner.Converters.FirstOrDefault(c => c is IdOrObjectConverter<T>);
if (self != null)
{
inner.Converters.Remove(self);
}
JsonSerializer.Serialize(writer, value, inner);
}
}
public interface IHasId
{
string Id { get; set; }
/// <summary>True when only the id was present in the JSON.</summary>
bool IsReferenceOnly { get; set; }
}
/// <summary>Shared serializer options for Floatplane JSON.</summary>
public static class FloatplaneJson
{
public static JsonSerializerOptions Options { get; } = Create();
private static JsonSerializerOptions Create()
{
var o = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
o.Converters.Add(new IdOrObjectConverter<ChannelModel>());
o.Converters.Add(new IdOrObjectConverter<CreatorModel>());
o.Converters.Add(new IdOrObjectConverter<VideoModel>());
o.Converters.Add(new JsonStringEnumConverter());
return o;
}
}

View File

@@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Floatplane.Api.Models;
/// <summary>A creator (e.g. LinusTechTips). Returned by creator/info, creator/named, creator/list.</summary>
public class CreatorModel : IHasId
{
public string Id { get; set; } = string.Empty;
[JsonIgnore]
public bool IsReferenceOnly { get; set; }
public string Title { get; set; } = string.Empty;
public string Urlname { get; set; } = string.Empty;
public string? Description { get; set; }
public string? About { get; set; }
public ImageModel? Cover { get; set; }
public ImageModel? Icon { get; set; }
public ImageModel? Card { get; set; }
public string? DefaultChannel { get; set; }
public List<ChannelModel> Channels { get; set; } = new();
public LiveStreamModel? LiveStream { get; set; }
public List<SubscriptionPlanModel> SubscriptionPlans { get; set; } = new();
}
/// <summary>A sub-channel of a creator (e.g. TechLinked under LinusTechTips).</summary>
public class ChannelModel : IHasId
{
public string Id { get; set; } = string.Empty;
[JsonIgnore]
public bool IsReferenceOnly { get; set; }
public string? Creator { get; set; }
public string Title { get; set; } = string.Empty;
public string Urlname { get; set; } = string.Empty;
public string? About { get; set; }
public int Order { get; set; }
public ImageModel? Cover { get; set; }
public ImageModel? Card { get; set; }
public ImageModel? Icon { get; set; }
}
public class LiveStreamModel
{
public string Id { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Description { get; set; }
public ImageModel? Thumbnail { get; set; }
public string? Owner { get; set; }
public string? Channel { get; set; }
public string? StreamPath { get; set; }
}
public class SubscriptionPlanModel
{
public string Id { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Price { get; set; }
public string? Currency { get; set; }
}
/// <summary>Entry of GET /api/v3/user/subscriptions.</summary>
public class SubscriptionModel
{
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public string? Interval { get; set; }
public bool PaymentCancelled { get; set; }
public SubscriptionPlanModel? Plan { get; set; }
/// <summary>Creator id (24 hex chars).</summary>
public string Creator { get; set; } = string.Empty;
}
/// <summary>GET /api/v3/user/self.</summary>
public class UserSelfModel
{
public string Id { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string? DisplayName { get; set; }
public ImageModel? ProfileImage { get; set; }
}

View File

@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Jellyfin.Plugin.Floatplane.Api.Models;
/// <summary>GET /api/v3/delivery/info response.</summary>
public class DeliveryInfoModel
{
public List<DeliveryGroup> Groups { get; set; } = new();
/// <summary>Flattens every usable variant with its absolute URL.</summary>
public IEnumerable<ResolvedVariant> Resolve()
{
foreach (var group in Groups)
{
var groupOrigin = group.Origins.FirstOrDefault()?.Url;
foreach (var v in group.Variants)
{
var origin = v.Origins?.FirstOrDefault()?.Url ?? groupOrigin;
if (string.IsNullOrEmpty(v.Url))
{
continue;
}
string url;
if (IsHttpUrl(v.Url))
{
url = v.Url;
}
else if (!string.IsNullOrEmpty(origin))
{
url = origin.TrimEnd('/') + "/" + v.Url.TrimStart('/');
}
else
{
continue;
}
yield return new ResolvedVariant(v, url);
}
}
}
/// <summary>
/// True for http(s) URLs only. <c>Uri.TryCreate(…, UriKind.Absolute)</c> must not be used
/// for this: on Linux a path like <c>/Videos/x.m3u8</c> parses as an absolute <c>file:///</c> URI.
/// </summary>
public static bool IsHttpUrl(string value)
=> value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
}
public class DeliveryGroup
{
public List<DeliveryOrigin> Origins { get; set; } = new();
public List<DeliveryVariant> Variants { get; set; } = new();
}
public class DeliveryOrigin
{
public string Url { get; set; } = string.Empty;
public string? QueryUrl { get; set; }
}
public class DeliveryVariant
{
/// <summary>e.g. "1080-avc1".</summary>
public string Name { get; set; } = string.Empty;
/// <summary>e.g. "1080p", "4K".</summary>
public string? Label { get; set; }
/// <summary>Relative (to origin) or absolute URL incl. signed token. Empty when denied.</summary>
public string Url { get; set; } = string.Empty;
public string? MimeType { get; set; }
public long Order { get; set; }
public bool Hidden { get; set; }
public bool Enabled { get; set; } = true;
public List<DeliveryOrigin>? Origins { get; set; }
public VariantMeta? Meta { get; set; }
public bool IsUsable => Enabled && !Hidden && !string.IsNullOrEmpty(Url);
public int Height => Meta?.Video?.Height ?? ParseHeight(Name);
public static int ParseHeight(string name)
{
var dash = name.IndexOf('-', StringComparison.Ordinal);
var head = dash > 0 ? name[..dash] : name;
return int.TryParse(head, out var h) ? h : 0;
}
}
public class VariantMeta
{
public VariantVideoMeta? Video { get; set; }
public VariantAudioMeta? Audio { get; set; }
public VariantCommonMeta? Common { get; set; }
public VariantLiveMeta? Live { get; set; }
}
public class VariantVideoMeta
{
public string? Codec { get; set; }
public string? CodecSimple { get; set; }
public BitrateMeta? Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public bool IsHdr { get; set; }
public double Fps { get; set; }
public string? MimeType { get; set; }
}
public class VariantAudioMeta
{
public string? Codec { get; set; }
public BitrateMeta? Bitrate { get; set; }
public int ChannelCount { get; set; }
public int Samplerate { get; set; }
public string? MimeType { get; set; }
}
public class BitrateMeta
{
public long Average { get; set; }
public long? Maximum { get; set; }
}
public class VariantCommonMeta
{
public VariantAccess? Access { get; set; }
}
public class VariantAccess
{
public string? DeniedReason { get; set; }
public string? DeniedMessage { get; set; }
}
public class VariantLiveMeta
{
public string? LowLatencyExtension { get; set; }
}
/// <summary>A delivery variant with its absolute URL.</summary>
public record ResolvedVariant(DeliveryVariant Variant, string Url)
{
public string Name => Variant.Name;
public int Height => Variant.Height;
public bool IsUsable => Variant.IsUsable;
}

View File

@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Floatplane.Api.Models;
/// <summary>
/// A blog post (the unit shown in Floatplane's feed). List endpoints return posts
/// without attachment details; GET /api/v3/content/post expands them.
/// </summary>
public class PostModel : IHasId
{
public string Id { get; set; } = string.Empty;
[JsonIgnore]
public bool IsReferenceOnly { get; set; }
public string? Guid { get; set; }
public string Title { get; set; } = string.Empty;
/// <summary>HTML body.</summary>
public string? Text { get; set; }
public string? TextMarkdown { get; set; }
public string? Type { get; set; }
public List<string> Tags { get; set; } = new();
public List<string> AttachmentOrder { get; set; } = new();
public DateTimeOffset? ReleaseDate { get; set; }
public int Likes { get; set; }
public int Dislikes { get; set; }
public int Score { get; set; }
public int Comments { get; set; }
public bool IsAccessible { get; set; } = true;
public bool WasReleasedSilently { get; set; }
public PostMetadata? Metadata { get; set; }
public ImageModel? Thumbnail { get; set; }
public CreatorModel? Creator { get; set; }
public ChannelModel? Channel { get; set; }
/// <summary>Full objects from /content/post; ids (or missing) from list endpoints.</summary>
public List<VideoModel>? VideoAttachments { get; set; }
[JsonIgnore]
public bool HasVideo => Metadata?.HasVideo ?? (VideoAttachments is { Count: > 0 });
[JsonIgnore]
public double DurationSeconds => Metadata?.VideoDuration ?? 0;
}
public class PostMetadata
{
public bool HasVideo { get; set; }
public int VideoCount { get; set; }
public double VideoDuration { get; set; }
public bool HasAudio { get; set; }
public int AudioCount { get; set; }
public double AudioDuration { get; set; }
public bool HasPicture { get; set; }
public int PictureCount { get; set; }
public bool HasGallery { get; set; }
public int GalleryCount { get; set; }
public bool IsFeatured { get; set; }
public double DisplayDuration { get; set; }
}
/// <summary>A video attachment (GET /api/v3/content/video or expanded in /content/post).</summary>
public class VideoModel : IHasId
{
public string Id { get; set; } = string.Empty;
[JsonIgnore]
public bool IsReferenceOnly { get; set; }
public string? Guid { get; set; }
public string Title { get; set; } = string.Empty;
public string? Type { get; set; }
public string? Description { get; set; }
public double Duration { get; set; }
public string? Creator { get; set; }
public string? PrimaryBlogPost { get; set; }
public bool IsProcessing { get; set; }
public bool IsAccessible { get; set; } = true;
public List<VideoLevel> Levels { get; set; } = new();
public List<TextTrackModel> TextTracks { get; set; } = new();
public ImageModel? Thumbnail { get; set; }
public ImageModel? TimelineSprite { get; set; }
/// <summary>Resume position in seconds (only present when logged in).</summary>
public double? Progress { get; set; }
}
public class VideoLevel
{
/// <summary>"360", "480", "720", "1080", "2160".</summary>
public string Name { get; set; } = string.Empty;
public int Width { get; set; }
public int Height { get; set; }
public string? Label { get; set; }
public int Order { get; set; }
}
public class TextTrackModel
{
public string Id { get; set; } = string.Empty;
/// <summary>S3-presigned URL (expires after 15 minutes).</summary>
public string Src { get; set; } = string.Empty;
public string? Kind { get; set; }
public string? Language { get; set; }
public bool Generated { get; set; }
public bool Processing { get; set; }
}
/// <summary>Entry of GET /api/v3/content/progress (percentage) or POST /content/get/progress.</summary>
public class ProgressModel
{
public string Id { get; set; } = string.Empty;
public double Progress { get; set; }
}