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:
201
Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs
Normal file
201
Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Channel;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using MediaBrowser.Common.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Api;
|
||||
|
||||
/// <summary>Helpers for the dashboard configuration page (admin only).</summary>
|
||||
[ApiController]
|
||||
[Route("Floatplane")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class FloatplaneAdminController : ControllerBase
|
||||
{
|
||||
private readonly FloatplaneApiClient _api;
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly DeliveryResolver _delivery;
|
||||
private readonly ILogger<FloatplaneAdminController> _logger;
|
||||
|
||||
public FloatplaneAdminController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneAdminController> logger)
|
||||
{
|
||||
_api = api;
|
||||
_catalog = catalog;
|
||||
_delivery = delivery;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Validates a session cookie (without saving it) and lists the subscriptions it has.</summary>
|
||||
[HttpPost("Session/Validate")]
|
||||
public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request)
|
||||
{
|
||||
var cookie = request.Cookie?.Trim() ?? string.Empty;
|
||||
if (cookie.StartsWith("sails.sid=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cookie = cookie["sails.sid=".Length..];
|
||||
}
|
||||
|
||||
cookie = cookie.Trim().TrimEnd(';');
|
||||
if (cookie.Length == 0)
|
||||
{
|
||||
return new SessionValidationResult { Ok = false, Message = "Cookie is empty" };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ct = HttpContext.RequestAborted;
|
||||
var self = await _api.GetSelfAsync(ct, cookie).ConfigureAwait(false);
|
||||
var subs = await _api.GetSubscriptionsAsync(ct, cookie).ConfigureAwait(false);
|
||||
return new SessionValidationResult
|
||||
{
|
||||
Ok = true,
|
||||
Username = self.Username,
|
||||
Cookie = cookie,
|
||||
Subscriptions = subs.Select(s => (s.Plan?.Title ?? "?") + " (" + s.Creator + ")").ToList(),
|
||||
Message = $"Logged in as {self.Username}, {subs.Count} active subscription(s)"
|
||||
};
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
return new SessionValidationResult { Ok = false, Message = "Floatplane rejected the cookie: " + ex.Message };
|
||||
}
|
||||
catch (FloatplaneApiException ex)
|
||||
{
|
||||
return new SessionValidationResult { Ok = false, Message = "Floatplane error: " + ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current plugin state for the settings page.</summary>
|
||||
[HttpGet("Status")]
|
||||
public ActionResult<PluginStatus> GetStatus()
|
||||
{
|
||||
return new PluginStatus
|
||||
{
|
||||
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
|
||||
HasSessionCookie = _api.HasSession,
|
||||
SessionState = _catalog.SessionState ?? "unknown",
|
||||
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
|
||||
LastError = _catalog.LastError,
|
||||
Generation = _catalog.Generation,
|
||||
LastRefresh = _catalog.LastRefresh == DateTimeOffset.MinValue ? null : _catalog.LastRefresh,
|
||||
Creators = _catalog.Creators.Select(c => new CreatorSummary
|
||||
{
|
||||
Id = c.Id,
|
||||
Title = c.Title,
|
||||
Urlname = c.Urlname,
|
||||
Channels = c.Channels.Select(ch => ch.Title).ToList()
|
||||
}).ToList(),
|
||||
CachedPostLists = _catalog.CachedPostListCount,
|
||||
CachedDeliveries = _delivery.CachedEntries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Drops caches and reloads creators (post lists refill on browse).</summary>
|
||||
[HttpPost("Refresh")]
|
||||
public async Task<ActionResult<PluginStatus>> Refresh()
|
||||
{
|
||||
_catalog.Invalidate();
|
||||
await _catalog.GetCreatorsAsync(true, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return GetStatus();
|
||||
}
|
||||
|
||||
/// <summary>Runs a Floatplane search (public endpoint) — for mapping/testing the search API.</summary>
|
||||
[HttpGet("Search")]
|
||||
public async Task<ActionResult<List<SearchHit>>> Search(
|
||||
[FromQuery] string creatorId,
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] string? channelId,
|
||||
[FromQuery] int limit = 20)
|
||||
{
|
||||
var posts = await _catalog.FetchPostsAsync(creatorId, channelId ?? string.Empty, Math.Clamp(limit, 1, 100), HttpContext.RequestAborted, q).ConfigureAwait(false);
|
||||
return posts.Select(p => new SearchHit
|
||||
{
|
||||
Id = p.Id,
|
||||
Title = p.Title,
|
||||
Channel = p.Channel?.Title ?? p.Channel?.Id,
|
||||
ReleaseDate = p.ReleaseDate,
|
||||
DurationSeconds = p.DurationSeconds,
|
||||
IsAccessible = p.IsAccessible,
|
||||
Thumbnail = p.Thumbnail?.BestPath(400)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Tags Floatplane knows for a creator (public endpoint).</summary>
|
||||
[HttpGet("Tags")]
|
||||
public Task<Dictionary<string, int>> Tags([FromQuery] string creatorId)
|
||||
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
public class SessionValidationRequest
|
||||
{
|
||||
public string? Cookie { get; set; }
|
||||
}
|
||||
|
||||
public class SessionValidationResult
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? Cookie { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
|
||||
public List<string> Subscriptions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PluginStatus
|
||||
{
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
public bool HasSessionCookie { get; set; }
|
||||
|
||||
public string SessionState { get; set; } = string.Empty;
|
||||
|
||||
public string SessionUsername { get; set; } = string.Empty;
|
||||
|
||||
public string? LastError { get; set; }
|
||||
|
||||
public int Generation { get; set; }
|
||||
|
||||
public DateTimeOffset? LastRefresh { get; set; }
|
||||
|
||||
public List<CreatorSummary> Creators { get; set; } = new();
|
||||
|
||||
public int CachedPostLists { get; set; }
|
||||
|
||||
public int CachedDeliveries { get; set; }
|
||||
}
|
||||
|
||||
public class CreatorSummary
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Urlname { get; set; } = string.Empty;
|
||||
|
||||
public List<string> Channels { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SearchHit
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public DateTimeOffset? ReleaseDate { get; set; }
|
||||
|
||||
public double DurationSeconds { get; set; }
|
||||
|
||||
public bool IsAccessible { get; set; }
|
||||
|
||||
public string? Thumbnail { get; set; }
|
||||
}
|
||||
369
Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs
Normal file
369
Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs
Normal file
@@ -0,0 +1,369 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Thin typed client for Floatplane's (unofficial) REST API. See
|
||||
/// <c>doc/floatplane-api-notes.md</c> for which endpoints are public and which need the
|
||||
/// session cookie.
|
||||
/// </summary>
|
||||
public class FloatplaneApiClient
|
||||
{
|
||||
public const string BaseUrl = "https://www.floatplane.com";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<FloatplaneApiClient> _logger;
|
||||
private readonly Func<PluginConfiguration> _config;
|
||||
|
||||
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger)
|
||||
: this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Constructor for tests: configuration is supplied explicitly.</summary>
|
||||
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>True when a session cookie is configured (not necessarily valid).</summary>
|
||||
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie);
|
||||
|
||||
// ───────────────────────────── auth / user ─────────────────────────────
|
||||
|
||||
/// <summary>GET /api/v3/user/self — validates the cookie. Throws <see cref="FloatplaneAuthException"/> if invalid.</summary>
|
||||
public Task<UserSelfModel> GetSelfAsync(CancellationToken ct, string? cookieOverride = null)
|
||||
=> GetAsync<UserSelfModel>("/api/v3/user/self", null, ct, cookieOverride);
|
||||
|
||||
/// <summary>GET /api/v3/user/subscriptions (login required).</summary>
|
||||
public Task<List<SubscriptionModel>> GetSubscriptionsAsync(CancellationToken ct, string? cookieOverride = null)
|
||||
=> GetAsync<List<SubscriptionModel>>("/api/v3/user/subscriptions", new() { ["active"] = "true" }, ct, cookieOverride);
|
||||
|
||||
// ───────────────────────────── creators ─────────────────────────────
|
||||
|
||||
/// <summary>GET /api/v3/creator/named?creatorURL[]= (public).</summary>
|
||||
public Task<List<CreatorModel>> GetCreatorsByUrlNameAsync(IEnumerable<string> urlNames, CancellationToken ct)
|
||||
{
|
||||
var q = new Dictionary<string, string>();
|
||||
var i = 0;
|
||||
foreach (var n in urlNames)
|
||||
{
|
||||
q[$"creatorURL[{i++}]"] = n;
|
||||
}
|
||||
|
||||
return GetAsync<List<CreatorModel>>("/api/v3/creator/named", q, ct);
|
||||
}
|
||||
|
||||
/// <summary>GET /api/v3/creator/info?id= (login required).</summary>
|
||||
public Task<CreatorModel> GetCreatorAsync(string creatorId, CancellationToken ct)
|
||||
=> GetAsync<CreatorModel>("/api/v3/creator/info", new() { ["id"] = creatorId }, ct);
|
||||
|
||||
/// <summary>GET /api/v3/creator/channels/list?ids[]= (public).</summary>
|
||||
public Task<List<ChannelModel>> GetCreatorChannelsAsync(IEnumerable<string> creatorIds, CancellationToken ct)
|
||||
{
|
||||
var q = new Dictionary<string, string>();
|
||||
var i = 0;
|
||||
foreach (var id in creatorIds)
|
||||
{
|
||||
q[$"ids[{i++}]"] = id;
|
||||
}
|
||||
|
||||
return GetAsync<List<ChannelModel>>("/api/v3/creator/channels/list", q, ct);
|
||||
}
|
||||
|
||||
/// <summary>GET /api/v3/content/tags?creatorIds[]= (public) → tag → count.</summary>
|
||||
public Task<Dictionary<string, int>> GetTagsAsync(string creatorId, CancellationToken ct)
|
||||
=> GetAsync<Dictionary<string, int>>("/api/v3/content/tags", new() { ["creatorIds[0]"] = creatorId }, ct);
|
||||
|
||||
// ───────────────────────────── content ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/v3/content/creator (public). <c>FetchAfter</c> is an offset
|
||||
/// (multiples of <c>Limit</c>); max limit is 20. Returns an empty list at the end.
|
||||
/// </summary>
|
||||
public Task<List<PostModel>> ListPostsAsync(PostQuery query, CancellationToken ct)
|
||||
{
|
||||
var q = new Dictionary<string, string>
|
||||
{
|
||||
["id"] = query.CreatorId,
|
||||
["limit"] = Math.Clamp(query.Limit, 1, 20).ToString(CultureInfo.InvariantCulture),
|
||||
["fetchAfter"] = query.FetchAfter.ToString(CultureInfo.InvariantCulture),
|
||||
["sort"] = query.SortAscending ? "ASC" : "DESC"
|
||||
};
|
||||
if (!string.IsNullOrEmpty(query.ChannelId))
|
||||
{
|
||||
q["channel"] = query.ChannelId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Search))
|
||||
{
|
||||
q["search"] = query.Search;
|
||||
}
|
||||
|
||||
if (query.HasVideo)
|
||||
{
|
||||
q["hasVideo"] = "true";
|
||||
}
|
||||
|
||||
if (query.Tags != null)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var t in query.Tags)
|
||||
{
|
||||
q[$"tags[{i++}]"] = t;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.FromDate.HasValue)
|
||||
{
|
||||
q["fromDate"] = query.FromDate.Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (query.ToDate.HasValue)
|
||||
{
|
||||
q["toDate"] = query.ToDate.Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (query.FromDurationSeconds.HasValue)
|
||||
{
|
||||
q["fromDuration"] = query.FromDurationSeconds.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (query.ToDurationSeconds.HasValue)
|
||||
{
|
||||
q["toDuration"] = query.ToDurationSeconds.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return GetAsync<List<PostModel>>("/api/v3/content/creator", q, ct);
|
||||
}
|
||||
|
||||
/// <summary>GET /api/v3/content/post?id= (login required) — expands attachments.</summary>
|
||||
public Task<PostModel> GetPostAsync(string postId, CancellationToken ct)
|
||||
=> GetAsync<PostModel>("/api/v3/content/post", new() { ["id"] = postId }, ct);
|
||||
|
||||
/// <summary>GET /api/v3/content/video?id= (login required).</summary>
|
||||
public Task<VideoModel> GetVideoAsync(string videoId, CancellationToken ct)
|
||||
=> GetAsync<VideoModel>("/api/v3/content/video", new() { ["id"] = videoId }, ct);
|
||||
|
||||
/// <summary>GET /api/v3/content/progress?ids[]=&contentType=blogPost (login) → percentage per post.</summary>
|
||||
public Task<List<ProgressModel>> GetProgressAsync(IEnumerable<string> postIds, CancellationToken ct)
|
||||
{
|
||||
var q = new Dictionary<string, string> { ["contentType"] = "blogPost" };
|
||||
var i = 0;
|
||||
foreach (var id in postIds)
|
||||
{
|
||||
q[$"ids[{i++}]"] = id;
|
||||
}
|
||||
|
||||
return GetAsync<List<ProgressModel>>("/api/v3/content/progress", q, ct);
|
||||
}
|
||||
|
||||
/// <summary>POST /api/v3/content/progress {id, contentType:"video", progress:seconds} (login).</summary>
|
||||
public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds });
|
||||
using var request = BuildRequest(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null);
|
||||
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ───────────────────────────── delivery ─────────────────────────────
|
||||
|
||||
/// <summary>GET /api/v3/delivery/info?scenario=&entityId= (login; rate-limited ≈2/min).</summary>
|
||||
public Task<DeliveryInfoModel> GetDeliveryInfoAsync(string scenario, string entityId, CancellationToken ct)
|
||||
=> GetAsync<DeliveryInfoModel>("/api/v3/delivery/info", new() { ["scenario"] = scenario, ["entityId"] = entityId }, ct);
|
||||
|
||||
/// <summary>Fetches a (sub)playlist from the CDN. No cookie — the signed token is enough.</summary>
|
||||
public async Task<string> GetPlaylistAsync(string url, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Referrer = new Uri(BaseUrl + "/");
|
||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the HLS AES-128 key (<c>/api/video/watchKey?token=</c>). This is the one
|
||||
/// playback request that needs the session cookie.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GetWatchKeyAsync(string keyUrl, CancellationToken ct)
|
||||
{
|
||||
using var request = BuildRequest(HttpMethod.Get, keyUrl, null, null);
|
||||
request.Headers.Referrer = new Uri(BaseUrl + "/");
|
||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Opens an upstream media request (MP4 relay) with optional Range; caller disposes.</summary>
|
||||
public async Task<HttpResponseMessage> OpenMediaAsync(HttpMethod method, string url, RangeHeaderValue? range, CancellationToken ct)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
request.Headers.Referrer = new Uri(BaseUrl + "/");
|
||||
if (range != null)
|
||||
{
|
||||
request.Headers.Range = range;
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName);
|
||||
return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ───────────────────────────── plumbing ─────────────────────────────
|
||||
|
||||
internal HttpRequestMessage BuildRequest(HttpMethod method, string url, Dictionary<string, string>? query, string? cookieOverride)
|
||||
{
|
||||
if (query is { Count: > 0 })
|
||||
{
|
||||
var sb = new StringBuilder(url);
|
||||
sb.Append(url.Contains('?', StringComparison.Ordinal) ? '&' : '?');
|
||||
var first = true;
|
||||
foreach (var (k, v) in query)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
sb.Append('&');
|
||||
}
|
||||
|
||||
first = false;
|
||||
sb.Append(Uri.EscapeDataString(k)).Append('=').Append(Uri.EscapeDataString(v));
|
||||
}
|
||||
|
||||
url = sb.ToString();
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
var cookie = cookieOverride ?? _config().SessionCookie;
|
||||
if (!string.IsNullOrWhiteSpace(cookie))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Cookie", "sails.sid=" + cookie.Trim());
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<T> GetAsync<T>(string path, Dictionary<string, string>? query, CancellationToken ct, string? cookieOverride = null)
|
||||
{
|
||||
using var request = BuildRequest(HttpMethod.Get, BaseUrl + path, query, cookieOverride);
|
||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("GET {Url} -> {Status}, {Length} bytes", request.RequestUri, (int)response.StatusCode, json.Length);
|
||||
T? result;
|
||||
try
|
||||
{
|
||||
result = JsonSerializer.Deserialize<T>(json, FloatplaneJson.Options);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not parse Floatplane response from {Path}: {Head}", path, json.Length > 300 ? json[..300] : json);
|
||||
throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected response from " + path);
|
||||
}
|
||||
|
||||
return result ?? throw new FloatplaneApiException(response.StatusCode, "emptyResponse", "Empty response from " + path);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName);
|
||||
return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retry = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60);
|
||||
_logger.LogWarning("Floatplane rate limit hit on {Url}; retry after {Retry}", response.RequestMessage?.RequestUri, retry);
|
||||
throw new FloatplaneRateLimitException(retry, "Floatplane rate limit (429)");
|
||||
}
|
||||
|
||||
string body = string.Empty;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// ignore – we only need the body for the error name
|
||||
}
|
||||
|
||||
ApiErrorModel? error = null;
|
||||
if (body.StartsWith('{'))
|
||||
{
|
||||
try
|
||||
{
|
||||
error = JsonSerializer.Deserialize<ApiErrorModel>(body, FloatplaneJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// not an envelope
|
||||
}
|
||||
}
|
||||
|
||||
var name = error?.Errors.FirstOrDefault()?.Name;
|
||||
var message = error?.Message ?? error?.Errors.FirstOrDefault()?.Message ?? $"HTTP {(int)response.StatusCode} from Floatplane";
|
||||
var url = response.RequestMessage?.RequestUri?.GetLeftPart(UriPartial.Path);
|
||||
_logger.LogDebug("Floatplane error {Status} {Name} on {Url}: {Message}", (int)response.StatusCode, name, url, message);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
|
||||
|| string.Equals(name, "notLoggedInError", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "notSubscribedError", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new FloatplaneAuthException(response.StatusCode, name, message);
|
||||
}
|
||||
|
||||
throw new FloatplaneApiException(response.StatusCode, name, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parameters of GET /api/v3/content/creator.</summary>
|
||||
public class PostQuery
|
||||
{
|
||||
public string CreatorId { get; set; } = string.Empty;
|
||||
|
||||
public string? ChannelId { get; set; }
|
||||
|
||||
public int Limit { get; set; } = 20;
|
||||
|
||||
public int FetchAfter { get; set; }
|
||||
|
||||
public string? Search { get; set; }
|
||||
|
||||
public bool HasVideo { get; set; }
|
||||
|
||||
public bool SortAscending { get; set; }
|
||||
|
||||
public IEnumerable<string>? Tags { get; set; }
|
||||
|
||||
public DateTimeOffset? FromDate { get; set; }
|
||||
|
||||
public DateTimeOffset? ToDate { get; set; }
|
||||
|
||||
public int? FromDurationSeconds { get; set; }
|
||||
|
||||
public int? ToDurationSeconds { get; set; }
|
||||
}
|
||||
41
Jellyfin.Plugin.Floatplane/Api/FloatplaneExceptions.cs
Normal file
41
Jellyfin.Plugin.Floatplane/Api/FloatplaneExceptions.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Api;
|
||||
|
||||
/// <summary>Floatplane answered with an error envelope.</summary>
|
||||
public class FloatplaneApiException : Exception
|
||||
{
|
||||
public FloatplaneApiException(HttpStatusCode status, string? errorName, string message)
|
||||
: base(message)
|
||||
{
|
||||
Status = status;
|
||||
ErrorName = errorName;
|
||||
}
|
||||
|
||||
public HttpStatusCode Status { get; }
|
||||
|
||||
/// <summary>e.g. notLoggedInError, notSubscribedError, paramValidationError.</summary>
|
||||
public string? ErrorName { get; }
|
||||
}
|
||||
|
||||
/// <summary>The session cookie is missing, expired or not subscribed to the item.</summary>
|
||||
public class FloatplaneAuthException : FloatplaneApiException
|
||||
{
|
||||
public FloatplaneAuthException(HttpStatusCode status, string? errorName, string message)
|
||||
: base(status, errorName, message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP 429 from Floatplane or Cloudflare.</summary>
|
||||
public class FloatplaneRateLimitException : FloatplaneApiException
|
||||
{
|
||||
public FloatplaneRateLimitException(TimeSpan retryAfter, string message)
|
||||
: base(HttpStatusCode.TooManyRequests, "rateLimited", message)
|
||||
{
|
||||
RetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
public TimeSpan RetryAfter { get; }
|
||||
}
|
||||
258
Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs
Normal file
258
Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Channel;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous, signature-protected endpoints that Jellyfin's ffmpeg (or a client) plays
|
||||
/// from. Floatplane's HLS is AES-128 encrypted and the key request needs the session
|
||||
/// cookie, which Jellyfin cannot forward — so the playlist is served from here with the
|
||||
/// key URI pointing back at <c>…/Key</c>. Segments stay on the CDN (signed token, CORS open).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("Floatplane/Stream")]
|
||||
public class FloatplaneStreamController : ControllerBase
|
||||
{
|
||||
private readonly FloatplaneApiClient _api;
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly DeliveryResolver _delivery;
|
||||
private readonly ILogger<FloatplaneStreamController> _logger;
|
||||
|
||||
public FloatplaneStreamController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneStreamController> logger)
|
||||
{
|
||||
_api = api;
|
||||
_catalog = catalog;
|
||||
_delivery = delivery;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>HLS media playlist for one quality level, rewritten for the proxy.</summary>
|
||||
[HttpGet("{postId}/{videoId}/{level}/index.m3u8")]
|
||||
[Produces("application/vnd.apple.mpegurl")]
|
||||
public async Task<IActionResult> GetPlaylist(
|
||||
[FromRoute] string postId,
|
||||
[FromRoute] string videoId,
|
||||
[FromRoute] string level,
|
||||
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
|
||||
{
|
||||
if (!ProxySignature.Verify(t, "hls", postId, videoId, level))
|
||||
{
|
||||
_logger.LogWarning("Rejected unsigned playlist request for {Post}/{Video}", postId, videoId);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var ct = HttpContext.RequestAborted;
|
||||
try
|
||||
{
|
||||
var variants = await _delivery.GetVariantsAsync(DeliveryResolver.ScenarioOnDemand, videoId, ct).ConfigureAwait(false);
|
||||
var variant = DeliveryResolver.SelectVariant(variants, level);
|
||||
if (variant == null)
|
||||
{
|
||||
var denied = variants.Select(v => v.Variant.Meta?.Common?.Access?.DeniedMessage).FirstOrDefault(m => m != null);
|
||||
_logger.LogWarning("No usable delivery variant for {Video}: {Reason}", videoId, denied ?? "none enabled");
|
||||
return StatusCode(403, denied ?? "No playable variant");
|
||||
}
|
||||
|
||||
if (!string.Equals(variant.Name, level, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Level {Level} not available for {Video}; serving {Actual}", level, videoId, variant.Name);
|
||||
}
|
||||
|
||||
var playlistUrl = new Uri(variant.Url);
|
||||
var text = await _api.GetPlaylistAsync(variant.Url, ct).ConfigureAwait(false);
|
||||
var rewritten = HlsRewriter.Rewrite(
|
||||
text,
|
||||
playlistUrl,
|
||||
keyUri => ProxyUrl("Key", videoId, keyUri),
|
||||
playlistUri => ProxyUrl("Playlist", videoId, playlistUri));
|
||||
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
return Content(rewritten, "application/vnd.apple.mpegurl");
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
_logger.LogWarning("Floatplane refused delivery for {Video}: {Message}", videoId, ex.Message);
|
||||
return StatusCode(403, ex.Message);
|
||||
}
|
||||
catch (FloatplaneRateLimitException ex)
|
||||
{
|
||||
Response.Headers.RetryAfter = ((int)ex.RetryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return StatusCode(429, ex.Message);
|
||||
}
|
||||
catch (FloatplaneApiException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Delivery lookup failed for {Video}", videoId);
|
||||
return StatusCode(502, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nested playlist (master → media) proxy; same rewriting.</summary>
|
||||
[HttpGet("{videoId}/Playlist")]
|
||||
public async Task<IActionResult> GetNestedPlaylist(
|
||||
[FromRoute] string videoId,
|
||||
[FromQuery] string? u,
|
||||
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
|
||||
{
|
||||
var url = ProxySignature.DecodeUrl(u);
|
||||
if (url == null || !ProxySignature.Verify(t, "Playlist", videoId, url))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var text = await _api.GetPlaylistAsync(url, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
var rewritten = HlsRewriter.Rewrite(
|
||||
text,
|
||||
new Uri(url),
|
||||
keyUri => ProxyUrl("Key", videoId, keyUri),
|
||||
playlistUri => ProxyUrl("Playlist", videoId, playlistUri));
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
return Content(rewritten, "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
/// <summary>AES-128 key, fetched from Floatplane with the session cookie.</summary>
|
||||
[HttpGet("{videoId}/Key")]
|
||||
public async Task<IActionResult> GetKey(
|
||||
[FromRoute] string videoId,
|
||||
[FromQuery] string? u,
|
||||
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
|
||||
{
|
||||
var url = ProxySignature.DecodeUrl(u);
|
||||
if (url == null || !ProxySignature.Verify(t, "Key", videoId, url))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!url.StartsWith(FloatplaneApiClient.BaseUrl + "/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Refusing key fetch from unexpected host: {Url}", url);
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var key = await _api.GetWatchKeyAsync(url, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
return File(key, "application/octet-stream");
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
_logger.LogWarning("Key fetch refused for {Video}: {Message} (session cookie expired?)", videoId, ex.Message);
|
||||
return StatusCode(403, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Progressive MP4 relay (Floatplane "download" delivery) with Range passthrough.
|
||||
/// Experimental until verified that the token alone is accepted by the edge servers.
|
||||
/// </summary>
|
||||
[HttpGet("{postId}/{videoId}/{level}/video.mp4")]
|
||||
[HttpHead("{postId}/{videoId}/{level}/video.mp4")]
|
||||
public async Task GetMp4(
|
||||
[FromRoute] string postId,
|
||||
[FromRoute] string videoId,
|
||||
[FromRoute] string level,
|
||||
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
|
||||
{
|
||||
if (!ProxySignature.Verify(t, "mp4", postId, videoId, level))
|
||||
{
|
||||
Response.StatusCode = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
var ct = HttpContext.RequestAborted;
|
||||
ResolvedVariant? variant;
|
||||
try
|
||||
{
|
||||
var variants = await _delivery.GetVariantsAsync(DeliveryResolver.ScenarioDownload, videoId, ct).ConfigureAwait(false);
|
||||
variant = DeliveryResolver.SelectVariant(variants, level);
|
||||
}
|
||||
catch (FloatplaneApiException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "download delivery failed for {Video}", videoId);
|
||||
Response.StatusCode = ex is FloatplaneRateLimitException ? 429 : 502;
|
||||
return;
|
||||
}
|
||||
|
||||
if (variant == null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
RangeHeaderValue? range = null;
|
||||
if (Request.Headers.TryGetValue("Range", out var rangeHeader) && !RangeHeaderValue.TryParse(rangeHeader.ToString(), out range))
|
||||
{
|
||||
range = null;
|
||||
}
|
||||
|
||||
var method = HttpMethods.IsHead(Request.Method) ? HttpMethod.Head : HttpMethod.Get;
|
||||
HttpResponseMessage upstream;
|
||||
try
|
||||
{
|
||||
upstream = await _api.OpenMediaAsync(method, variant.Url, range, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Upstream MP4 request failed for {Video}", videoId);
|
||||
Response.StatusCode = 502;
|
||||
return;
|
||||
}
|
||||
|
||||
using var response = upstream;
|
||||
Response.StatusCode = (int)response.StatusCode;
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && response.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = response.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
Response.Headers.AcceptRanges = "bytes";
|
||||
Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "video/mp4";
|
||||
if (response.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
if (method == HttpMethod.Head)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
await stream.CopyToAsync(Response.Body, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Client aborted MP4 stream for {Video}", videoId);
|
||||
}
|
||||
}
|
||||
|
||||
private string ProxyUrl(string kind, string videoId, string upstreamUrl)
|
||||
{
|
||||
var signature = ProxySignature.Create(kind, videoId, upstreamUrl);
|
||||
var encoded = ProxySignature.EncodeUrl(upstreamUrl);
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
return $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(videoId)}/{kind}?u={encoded}&{ProxySignature.QueryParameter}={signature}";
|
||||
}
|
||||
}
|
||||
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; }
|
||||
}
|
||||
63
Jellyfin.Plugin.Floatplane/Api/ProxySignature.cs
Normal file
63
Jellyfin.Plugin.Floatplane/Api/ProxySignature.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Signs the URLs the channel hands to Jellyfin's ffmpeg, so the anonymous stream
|
||||
/// endpoints only serve what this plugin selected. The secret lives for the process:
|
||||
/// media sources are handed out per playback, so a restart just means fresh signatures.
|
||||
/// </summary>
|
||||
public static class ProxySignature
|
||||
{
|
||||
public const string QueryParameter = "t";
|
||||
|
||||
private static readonly byte[] Secret = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
public static string Create(params string[] parts)
|
||||
{
|
||||
var payload = string.Join('\n', parts);
|
||||
var hash = HMACSHA256.HashData(Secret, Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool Verify(string? signature, params string[] parts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = Create(parts);
|
||||
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));
|
||||
}
|
||||
|
||||
/// <summary>URL-safe base64 without padding (for embedding upstream URLs in proxy URLs).</summary>
|
||||
public static string EncodeUrl(string value)
|
||||
=> Convert.ToBase64String(Encoding.UTF8.GetBytes(value)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static string? DecodeUrl(string? encoded)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encoded))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var s = encoded.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(Convert.FromBase64String(s));
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user