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;
}
}