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
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:
127
Jellyfin.Plugin.Floatplane/Api/Models/Common.cs
Normal file
127
Jellyfin.Plugin.Floatplane/Api/Models/Common.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
119
Jellyfin.Plugin.Floatplane/Api/Models/Creator.cs
Normal file
119
Jellyfin.Plugin.Floatplane/Api/Models/Creator.cs
Normal 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; }
|
||||
}
|
||||
177
Jellyfin.Plugin.Floatplane/Api/Models/Delivery.cs
Normal file
177
Jellyfin.Plugin.Floatplane/Api/Models/Delivery.cs
Normal 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;
|
||||
}
|
||||
166
Jellyfin.Plugin.Floatplane/Api/Models/Post.cs
Normal file
166
Jellyfin.Plugin.Floatplane/Api/Models/Post.cs
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user