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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user