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;
|
||||
}
|
||||
}
|
||||
}
|
||||
350
Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs
Normal file
350
Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs
Normal file
@@ -0,0 +1,350 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory catalogue: which creators to show, their sub-channels, and the newest posts
|
||||
/// per channel. Everything here is cheap public listing data; post details and delivery
|
||||
/// (login required) live in <see cref="Playback.DeliveryResolver"/> and the channel.
|
||||
/// </summary>
|
||||
public class CatalogService
|
||||
{
|
||||
private readonly FloatplaneApiClient _api;
|
||||
private readonly ILogger<CatalogService> _logger;
|
||||
private readonly Func<PluginConfiguration> _config;
|
||||
private readonly SemaphoreSlim _creatorLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, CachedPosts> _posts = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CachedPost> _postDetails = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _postLocks = new(StringComparer.Ordinal);
|
||||
|
||||
private IReadOnlyList<CreatorModel> _creators = Array.Empty<CreatorModel>();
|
||||
private DateTimeOffset _creatorsFetched = DateTimeOffset.MinValue;
|
||||
private int _generation;
|
||||
|
||||
public CatalogService(FloatplaneApiClient api, ILogger<CatalogService> logger)
|
||||
: this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
|
||||
{
|
||||
}
|
||||
|
||||
public CatalogService(FloatplaneApiClient api, ILogger<CatalogService> logger, Func<PluginConfiguration> config)
|
||||
{
|
||||
_api = api;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>True when a session cookie is configured.</summary>
|
||||
public bool HasSession => _api.HasSession;
|
||||
|
||||
/// <summary>Bumped on every refresh so Jellyfin's 3-hour listing cache is bypassed.</summary>
|
||||
public int Generation => _generation;
|
||||
|
||||
public DateTimeOffset LastRefresh => _creatorsFetched;
|
||||
|
||||
/// <summary>Session state as last observed (null = not checked yet).</summary>
|
||||
public string? SessionState { get; private set; }
|
||||
|
||||
public string? LastError { get; private set; }
|
||||
|
||||
/// <summary>Cached creators (may be empty before the first refresh).</summary>
|
||||
public IReadOnlyList<CreatorModel> Creators => _creators;
|
||||
|
||||
public int CachedPostListCount => _posts.Count;
|
||||
|
||||
/// <summary>Invalidates every cache and bumps the generation.</summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
_posts.Clear();
|
||||
_postDetails.Clear();
|
||||
_creatorsFetched = DateTimeOffset.MinValue;
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
|
||||
/// <summary>Full refresh: creators + channels. Post lists are refetched lazily on browse.</summary>
|
||||
public async Task RefreshAsync(IProgress<double>? progress, CancellationToken ct)
|
||||
{
|
||||
_posts.Clear();
|
||||
_postDetails.Clear();
|
||||
await GetCreatorsAsync(true, ct).ConfigureAwait(false);
|
||||
progress?.Report(50);
|
||||
|
||||
// Warm the first page of every channel so the first browse is instant.
|
||||
var creators = _creators;
|
||||
var total = Math.Max(1, creators.Sum(c => Math.Max(1, c.Channels.Count)));
|
||||
var done = 0;
|
||||
foreach (var creator in creators)
|
||||
{
|
||||
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { string.Empty };
|
||||
foreach (var channelId in channelIds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await GetPostsAsync(creator.Id, channelId, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not warm posts for {Creator}/{Channel}", creator.Urlname, channelId);
|
||||
}
|
||||
|
||||
done++;
|
||||
progress?.Report(50 + (50.0 * done / total));
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creators to show: the account's subscriptions (when a valid session exists) plus the
|
||||
/// configured extra url-names. Public data is used wherever possible so browsing works
|
||||
/// without a session.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<CreatorModel>> GetCreatorsAsync(bool force, CancellationToken ct)
|
||||
{
|
||||
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
|
||||
if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl)
|
||||
{
|
||||
return _creators;
|
||||
}
|
||||
|
||||
await _creatorLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!force && _creators.Count > 0 && DateTimeOffset.UtcNow - _creatorsFetched < ttl)
|
||||
{
|
||||
return _creators;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, CreatorModel>(StringComparer.Ordinal);
|
||||
var cfg = _config();
|
||||
|
||||
if (_api.HasSession)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subs = await _api.GetSubscriptionsAsync(ct).ConfigureAwait(false);
|
||||
SessionState = "ok";
|
||||
LastError = null;
|
||||
foreach (var creatorId in subs.Select(s => s.Creator).Where(id => !string.IsNullOrEmpty(id)).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
var creator = await _api.GetCreatorAsync(creatorId, ct).ConfigureAwait(false);
|
||||
result[creator.Id] = creator;
|
||||
}
|
||||
catch (FloatplaneApiException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not load subscribed creator {Id}", creatorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
SessionState = "invalid";
|
||||
LastError = ex.Message;
|
||||
_logger.LogWarning("Floatplane session cookie rejected ({Message}); paste a fresh sails.sid in the plugin settings", ex.Message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SessionState = "none";
|
||||
}
|
||||
|
||||
var extra = (cfg.ExtraCreators ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(n => n.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (extra.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var named = await _api.GetCreatorsByUrlNameAsync(extra, ct).ConfigureAwait(false);
|
||||
foreach (var creator in named)
|
||||
{
|
||||
result.TryAdd(creator.Id, creator);
|
||||
}
|
||||
}
|
||||
catch (FloatplaneApiException ex)
|
||||
{
|
||||
LastError = ex.Message;
|
||||
_logger.LogWarning(ex, "Could not resolve extra creators {Names}", string.Join(",", extra));
|
||||
}
|
||||
}
|
||||
|
||||
// creator/named and creator/info both include channels; fill in if missing.
|
||||
var missing = result.Values.Where(c => c.Channels.Count == 0).Select(c => c.Id).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var channels = await _api.GetCreatorChannelsAsync(missing, ct).ConfigureAwait(false);
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
if (ch.Creator != null && result.TryGetValue(ch.Creator, out var creator))
|
||||
{
|
||||
creator.Channels.Add(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (FloatplaneApiException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "channels/list failed");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var creator in result.Values)
|
||||
{
|
||||
creator.Channels = creator.Channels.OrderBy(c => c.Order).ThenBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
_creators = result.Values.OrderBy(c => c.Title, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
_creatorsFetched = DateTimeOffset.UtcNow;
|
||||
_logger.LogInformation("Floatplane catalogue: {Count} creators ({Session})", _creators.Count, SessionState);
|
||||
return _creators;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_creatorLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public CreatorModel? FindCreator(string creatorId) => _creators.FirstOrDefault(c => string.Equals(c.Id, creatorId, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Newest video posts of a channel (<paramref name="channelId"/> empty = whole creator),
|
||||
/// up to <c>MaxPostsPerChannel</c>, cached for <c>CatalogCacheMinutes</c>.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<PostModel>> GetPostsAsync(string creatorId, string channelId, CancellationToken ct)
|
||||
{
|
||||
var key = creatorId + "|" + channelId;
|
||||
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
|
||||
if (_posts.TryGetValue(key, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < ttl)
|
||||
{
|
||||
return cached.Posts;
|
||||
}
|
||||
|
||||
var max = Math.Clamp(_config().MaxPostsPerChannel, 20, 2000);
|
||||
var posts = await FetchPostsAsync(creatorId, channelId, max, ct).ConfigureAwait(false);
|
||||
_posts[key] = new CachedPosts(posts, DateTimeOffset.UtcNow);
|
||||
return posts;
|
||||
}
|
||||
|
||||
/// <summary>Pages through /content/creator until <paramref name="max"/> posts or the end.</summary>
|
||||
public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null)
|
||||
{
|
||||
var list = new List<PostModel>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
// Without a session Floatplane reports every post as inaccessible, so the
|
||||
// "locked" filter only makes sense once we are logged in.
|
||||
var showLocked = _config().ShowLockedPosts || !_api.HasSession;
|
||||
for (var offset = 0; list.Count < max; offset += 20)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var page = await _api.ListPostsAsync(
|
||||
new PostQuery
|
||||
{
|
||||
CreatorId = creatorId,
|
||||
ChannelId = string.IsNullOrEmpty(channelId) ? null : channelId,
|
||||
Limit = 20,
|
||||
FetchAfter = offset,
|
||||
HasVideo = true,
|
||||
Search = search
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
if (page.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var post in page)
|
||||
{
|
||||
if (!post.HasVideo || !seen.Add(post.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!post.IsAccessible && !showLocked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(post);
|
||||
if (list.Count >= max)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (page.Count < 20)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Looks a post up in the cached lists (no network).</summary>
|
||||
public PostModel? FindCachedPost(string postId)
|
||||
{
|
||||
foreach (var entry in _posts.Values)
|
||||
{
|
||||
var hit = entry.Posts.FirstOrDefault(p => string.Equals(p.Id, postId, StringComparison.Ordinal));
|
||||
if (hit != null)
|
||||
{
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Post with expanded video attachments (login required). Cached for one hour.</summary>
|
||||
public async Task<PostModel> GetPostDetailsAsync(string postId, CancellationToken ct)
|
||||
{
|
||||
if (_postDetails.TryGetValue(postId, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1))
|
||||
{
|
||||
return cached.Post;
|
||||
}
|
||||
|
||||
var gate = _postLocks.GetOrAdd(postId, _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_postDetails.TryGetValue(postId, out cached) && DateTimeOffset.UtcNow - cached.Fetched < TimeSpan.FromHours(1))
|
||||
{
|
||||
return cached.Post;
|
||||
}
|
||||
|
||||
var post = await _api.GetPostAsync(postId, ct).ConfigureAwait(false);
|
||||
SessionState = "ok";
|
||||
_postDetails[postId] = new CachedPost(post, DateTimeOffset.UtcNow);
|
||||
return post;
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
SessionState = ex.ErrorName == "notSubscribedError" ? "ok" : "invalid";
|
||||
LastError = ex.Message;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CachedPosts(IReadOnlyList<PostModel> Posts, DateTimeOffset Fetched);
|
||||
|
||||
private sealed record CachedPost(PostModel Post, DateTimeOffset Fetched);
|
||||
}
|
||||
472
Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs
Normal file
472
Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs
Normal file
@@ -0,0 +1,472 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// The "Floatplane" channel: root → creators → sub-channels → video posts.
|
||||
/// Item ids are stable strings (<c>creator:…</c>, <c>channel:…</c>, <c>post:…</c>); a post
|
||||
/// only ever appears under its own channel, as Jellyfin allows one parent per item.
|
||||
/// </summary>
|
||||
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
|
||||
{
|
||||
private const string CreatorPrefix = "creator:";
|
||||
private const string ChannelPrefix = "channel:";
|
||||
private const string PostPrefix = "post:";
|
||||
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly ILogger<FloatplaneChannel> _logger;
|
||||
private readonly Func<PluginConfiguration> _config;
|
||||
|
||||
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_appHost = appHost;
|
||||
_logger = logger;
|
||||
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Floatplane"; // never rename: the channel's library id derives from it
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Your Floatplane subscriptions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DataVersion => "1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomePageUrl => "https://www.floatplane.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabledFor(string userId) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetCacheKey(string? userId) => "g" + _catalog.Generation.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <inheritdoc />
|
||||
public InternalChannelFeatures GetChannelFeatures() => new()
|
||||
{
|
||||
MediaTypes = [ChannelMediaType.Video],
|
||||
ContentTypes = [ChannelMediaContentType.Clip],
|
||||
DefaultSortFields = [ChannelItemSortField.PremiereDate, ChannelItemSortField.Name, ChannelItemSortField.Runtime],
|
||||
SupportsSortOrderToggle = true,
|
||||
MaxPageSize = 100,
|
||||
AutoRefreshLevels = 3,
|
||||
SupportsContentDownloading = false
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Floatplane.Resources.logo.png");
|
||||
return Task.FromResult(new DynamicImageResponse
|
||||
{
|
||||
HasImage = stream != null,
|
||||
Stream = stream,
|
||||
Format = ImageFormat.Png
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var folderId = query.FolderId ?? string.Empty;
|
||||
_logger.LogDebug("GetChannelItems folder='{Folder}' start={Start} limit={Limit}", folderId, query.StartIndex, query.Limit);
|
||||
|
||||
List<ChannelItemInfo> items;
|
||||
if (string.IsNullOrEmpty(folderId))
|
||||
{
|
||||
items = await GetRootItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(CreatorPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var rest = folderId[ChannelPrefix.Length..];
|
||||
var sep = rest.IndexOf(':', StringComparison.Ordinal);
|
||||
var creatorId = sep > 0 ? rest[..sep] : rest;
|
||||
var channelId = sep > 0 ? rest[(sep + 1)..] : string.Empty;
|
||||
items = await GetPostItemsAsync(creatorId, channelId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unknown folder id {Folder}", folderId);
|
||||
items = new List<ChannelItemInfo>();
|
||||
}
|
||||
|
||||
items = ApplySort(items, query);
|
||||
var total = items.Count;
|
||||
if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
|
||||
{
|
||||
items = items.Skip(query.StartIndex.Value).ToList();
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0 && items.Count > query.Limit.Value)
|
||||
{
|
||||
items = items.Take(query.Limit.Value).ToList();
|
||||
}
|
||||
|
||||
return new ChannelItemResult { Items = items, TotalRecordCount = total };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
|
||||
{
|
||||
var creators = await _catalog.GetCreatorsAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
var latest = new List<ChannelItemInfo>();
|
||||
foreach (var creator in creators)
|
||||
{
|
||||
try
|
||||
{
|
||||
var posts = await _catalog.FetchPostsAsync(creator.Id, string.Empty, 20, cancellationToken).ConfigureAwait(false);
|
||||
latest.AddRange(posts.Select(p => ToItem(p, creator, _catalog.HasSession)));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Latest media for {Creator} failed", creator.Urlname);
|
||||
}
|
||||
}
|
||||
|
||||
return latest.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).Take(40).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var postId = id[PostPrefix.Length..];
|
||||
PostModel post;
|
||||
try
|
||||
{
|
||||
post = await _catalog.GetPostDetailsAsync(postId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
_logger.LogWarning("Cannot play Floatplane post {Post}: {Message}. Paste a valid sails.sid cookie in the plugin settings.", postId, ex.Message);
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var attachments = OrderedVideos(post);
|
||||
if (attachments.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Post {Post} has no playable video attachment", postId);
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var cfg = _config();
|
||||
var baseUrl = ProxyBaseUrl();
|
||||
var sources = new List<MediaSourceInfo>();
|
||||
var first = true;
|
||||
foreach (var video in attachments)
|
||||
{
|
||||
var levels = video.Levels
|
||||
.Where(l => l.Height > 0 && l.Height <= Math.Max(360, cfg.MaxHeight))
|
||||
.OrderByDescending(l => Math.Abs(l.Height - DeliveryVariant.ParseHeight(cfg.PreferredQuality)) == 0 ? 1 : 0)
|
||||
.ThenByDescending(l => l.Height)
|
||||
.ToList();
|
||||
if (levels.Count == 0)
|
||||
{
|
||||
levels = video.Levels.OrderByDescending(l => l.Height).Take(1).ToList();
|
||||
}
|
||||
|
||||
if (levels.Count == 0)
|
||||
{
|
||||
// No level info (still processing?) — still try the preferred quality.
|
||||
levels = [new VideoLevel { Name = cfg.PreferredQuality, Height = DeliveryVariant.ParseHeight(cfg.PreferredQuality), Label = cfg.PreferredQuality + "p" }];
|
||||
}
|
||||
|
||||
var titlePrefix = attachments.Count > 1 ? (string.IsNullOrWhiteSpace(video.Title) ? "Video" : video.Title) + " · " : string.Empty;
|
||||
foreach (var level in levels)
|
||||
{
|
||||
var signature = ProxySignature.Create("hls", postId, video.Id, level.Name);
|
||||
var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/index.m3u8?{ProxySignature.QueryParameter}={signature}";
|
||||
sources.Add(BuildSource(id, first, titlePrefix + (level.Label ?? level.Name + "p"), path, "ts", video, level, hls: true));
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (cfg.EnableMp4Sources)
|
||||
{
|
||||
var level = levels[0];
|
||||
var signature = ProxySignature.Create("mp4", postId, video.Id, level.Name);
|
||||
var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/video.mp4?{ProxySignature.QueryParameter}={signature}";
|
||||
sources.Add(BuildSource(id, false, titlePrefix + (level.Label ?? level.Name + "p") + " (MP4)", path, "mp4", video, level, hls: false));
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// ───────────────────────────── folders ─────────────────────────────
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetRootItemsAsync(CancellationToken ct)
|
||||
{
|
||||
var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false);
|
||||
return creators.Select(c => new ChannelItemInfo
|
||||
{
|
||||
Id = CreatorPrefix + c.Id,
|
||||
Name = c.Title,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
Overview = c.Description,
|
||||
ImageUrl = c.Icon?.BestPath(600) ?? c.Card?.BestPath(1200) ?? c.Cover?.BestPath(1200),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + c.Urlname + "/home"
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetCreatorItemsAsync(string creatorId, CancellationToken ct)
|
||||
{
|
||||
var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false);
|
||||
var creator = creators.FirstOrDefault(c => c.Id == creatorId);
|
||||
if (creator == null)
|
||||
{
|
||||
return new List<ChannelItemInfo>();
|
||||
}
|
||||
|
||||
if (creator.Channels.Count == 0)
|
||||
{
|
||||
return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return creator.Channels.Select(ch => new ChannelItemInfo
|
||||
{
|
||||
Id = ChannelPrefix + creator.Id + ":" + ch.Id,
|
||||
Name = ch.Title,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
Overview = ch.About,
|
||||
ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct)
|
||||
{
|
||||
var creator = _catalog.FindCreator(creatorId);
|
||||
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
|
||||
// Anonymous listings flag everything as inaccessible; only mark locks when logged in.
|
||||
var markLocked = _catalog.HasSession;
|
||||
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
|
||||
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true)
|
||||
{
|
||||
var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = PostPrefix + post.Id,
|
||||
Name = name,
|
||||
Type = ChannelItemType.Media,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
ContentType = ChannelMediaContentType.Clip,
|
||||
Overview = Overview(post),
|
||||
ImageUrl = post.Thumbnail?.BestPath(1200),
|
||||
PremiereDate = post.ReleaseDate?.UtcDateTime,
|
||||
DateCreated = post.ReleaseDate?.UtcDateTime,
|
||||
ProductionYear = post.ReleaseDate?.Year,
|
||||
RunTimeTicks = post.DurationSeconds > 0 ? (long)(post.DurationSeconds * TimeSpan.TicksPerSecond) : null,
|
||||
HomePageUrl = "https://www.floatplane.com/post/" + post.Id,
|
||||
Tags = post.Tags.ToList(),
|
||||
Studios = creator != null ? [creator.Title] : [],
|
||||
Genres = post.Channel is { IsReferenceOnly: false, Title.Length: > 0 } ? [post.Channel.Title] : [],
|
||||
ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["Floatplane"] = post.Id }
|
||||
};
|
||||
if (post.Score != 0)
|
||||
{
|
||||
item.CommunityRating = (float)Math.Clamp(5 + Math.Log10(Math.Max(1, post.Score)) * 1.5, 0, 10);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private static string? Overview(PostModel post)
|
||||
{
|
||||
var text = post.TextMarkdown;
|
||||
if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(post.Text))
|
||||
{
|
||||
text = StripHtml(post.Text);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.Replace("\\-", "-", StringComparison.Ordinal).Replace("\\_", "_", StringComparison.Ordinal).Trim();
|
||||
return text.Length > 2000 ? text[..2000] + "…" : text;
|
||||
}
|
||||
|
||||
[GeneratedRegex("<br\\s*/?>|</p>|</div>|</li>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex BlockBreaks();
|
||||
|
||||
[GeneratedRegex("<[^>]+>", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AnyTag();
|
||||
|
||||
internal static string StripHtml(string html)
|
||||
{
|
||||
var text = BlockBreaks().Replace(html, "\n");
|
||||
text = AnyTag().Replace(text, string.Empty);
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
return Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
||||
}
|
||||
|
||||
private static List<ChannelItemInfo> ApplySort(List<ChannelItemInfo> items, InternalChannelItemQuery query)
|
||||
{
|
||||
IEnumerable<ChannelItemInfo> sorted = query.SortBy switch
|
||||
{
|
||||
ChannelItemSortField.Name => items.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase),
|
||||
ChannelItemSortField.Runtime => items.OrderBy(i => i.RunTimeTicks ?? 0),
|
||||
ChannelItemSortField.PremiereDate or ChannelItemSortField.DateCreated => items.OrderBy(i => i.PremiereDate ?? DateTime.MinValue),
|
||||
_ => null!
|
||||
};
|
||||
if (sorted == null)
|
||||
{
|
||||
// default: folders alphabetical as given, media newest first
|
||||
return items.All(i => i.Type == ChannelItemType.Folder)
|
||||
? items
|
||||
: items.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).ToList();
|
||||
}
|
||||
|
||||
return (query.SortDescending ? sorted.Reverse() : sorted).ToList();
|
||||
}
|
||||
|
||||
// ───────────────────────────── media sources ─────────────────────────────
|
||||
|
||||
private static List<VideoModel> OrderedVideos(PostModel post)
|
||||
{
|
||||
var videos = (post.VideoAttachments ?? new List<VideoModel>())
|
||||
.Where(v => !v.IsReferenceOnly && !v.IsProcessing)
|
||||
.ToList();
|
||||
if (post.AttachmentOrder.Count > 0)
|
||||
{
|
||||
videos = videos.OrderBy(v =>
|
||||
{
|
||||
var i = post.AttachmentOrder.IndexOf(v.Id);
|
||||
return i < 0 ? int.MaxValue : i;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
private string ProxyBaseUrl()
|
||||
{
|
||||
var configured = _config().ProxyBaseUrl?.Trim().TrimEnd('/');
|
||||
if (!string.IsNullOrEmpty(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
return _appHost.GetApiUrlForLocalAccess(null, false).TrimEnd('/');
|
||||
}
|
||||
|
||||
private static MediaSourceInfo BuildSource(string itemId, bool isPrimary, string name, string path, string container, VideoModel video, VideoLevel level, bool hls)
|
||||
{
|
||||
var width = level.Width > 0 ? level.Width : (int)Math.Round(level.Height * 16.0 / 9.0);
|
||||
var streams = new List<MediaStream>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = MediaStreamType.Video,
|
||||
Index = 0,
|
||||
Codec = "h264",
|
||||
Profile = "High",
|
||||
Level = level.Height >= 2160 ? 51 : level.Height >= 1080 ? 40 : 31,
|
||||
Width = width,
|
||||
Height = level.Height,
|
||||
IsDefault = true,
|
||||
IsInterlaced = false,
|
||||
AverageFrameRate = 30,
|
||||
RealFrameRate = 30,
|
||||
PixelFormat = "yuv420p",
|
||||
BitRate = EstimateBitrate(level.Height)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = MediaStreamType.Audio,
|
||||
Index = 1,
|
||||
Codec = "aac",
|
||||
Profile = "LC",
|
||||
Channels = 2,
|
||||
SampleRate = 48000,
|
||||
BitRate = 160000,
|
||||
IsDefault = true,
|
||||
Language = "eng"
|
||||
}
|
||||
};
|
||||
|
||||
return new MediaSourceInfo
|
||||
{
|
||||
// The first source keeps the item id (what clients echo back as mediaSourceId).
|
||||
Id = isPrimary ? DeterministicGuid(itemId).ToString("N") : DeterministicGuid($"{itemId}|{video.Id}|{level.Name}|{container}").ToString("N"),
|
||||
Name = name,
|
||||
Path = path,
|
||||
Protocol = MediaProtocol.Http,
|
||||
Container = container,
|
||||
VideoType = VideoType.VideoFile,
|
||||
IsRemote = false,
|
||||
IsInfiniteStream = false,
|
||||
RunTimeTicks = video.Duration > 0 ? (long)(video.Duration * TimeSpan.TicksPerSecond) : null,
|
||||
Bitrate = EstimateBitrate(level.Height) + 160000,
|
||||
SupportsProbing = false,
|
||||
SupportsDirectPlay = false,
|
||||
SupportsDirectStream = !hls,
|
||||
SupportsTranscoding = true,
|
||||
RequiresOpening = false,
|
||||
RequiresClosing = false,
|
||||
ReadAtNativeFramerate = false,
|
||||
DefaultAudioStreamIndex = 1,
|
||||
MediaStreams = streams
|
||||
};
|
||||
}
|
||||
|
||||
private static int EstimateBitrate(int height) => height switch
|
||||
{
|
||||
>= 2160 => 8_000_000,
|
||||
>= 1080 => 2_500_000,
|
||||
>= 720 => 1_200_000,
|
||||
>= 480 => 600_000,
|
||||
_ => 350_000
|
||||
};
|
||||
|
||||
internal static Guid DeterministicGuid(string input)
|
||||
{
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return new Guid(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin settings. Serialised to XML by Jellyfin
|
||||
/// (<c>plugins/configurations/Floatplane.xml</c>), so keep properties simple and public.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Value of the <c>sails.sid</c> cookie copied from a logged-in browser session.
|
||||
/// Floatplane's password login needs a Cloudflare Turnstile token, which a server
|
||||
/// cannot obtain, so the session is pasted in. Needed for playback; browsing is public.
|
||||
/// </summary>
|
||||
public string SessionCookie { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username reported by Floatplane the last time the cookie was validated (display only).
|
||||
/// </summary>
|
||||
public string SessionUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated creator url-names (e.g. <c>linustechtips</c>) to show in addition to
|
||||
/// (or, when not logged in, instead of) the account's subscriptions.
|
||||
/// </summary>
|
||||
public string ExtraCreators { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Preferred quality level name: 360, 480, 720, 1080 or 2160.</summary>
|
||||
public string PreferredQuality { get; set; } = "1080";
|
||||
|
||||
/// <summary>Highest level offered as a version at all (keeps 4K out of the list by default).</summary>
|
||||
public int MaxHeight { get; set; } = 1080;
|
||||
|
||||
/// <summary>How many posts (newest first) are listed per Floatplane channel.</summary>
|
||||
public int MaxPostsPerChannel { get; set; } = 100;
|
||||
|
||||
/// <summary>Show posts the account cannot access (higher tier) as locked items.</summary>
|
||||
public bool ShowLockedPosts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Also offer a progressive MP4 version (Floatplane "download" delivery). Experimental:
|
||||
/// the download URLs have not been verified to play with the signed token alone.
|
||||
/// </summary>
|
||||
public bool EnableMp4Sources { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base URL the server's own ffmpeg uses to reach the plugin's stream proxy, e.g.
|
||||
/// <c>http://127.0.0.1:8096</c>. Empty = Jellyfin's local API url.
|
||||
/// </summary>
|
||||
public string ProxyBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Budget for <c>/api/v3/delivery/info</c> calls (Floatplane rate-limits ≈2/min).</summary>
|
||||
public int DeliveryCallsPerMinute { get; set; } = 2;
|
||||
|
||||
/// <summary>How long listings are cached inside the plugin before Floatplane is asked again.</summary>
|
||||
public int CatalogCacheMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>Hours between automatic catalogue refreshes (scheduled task).</summary>
|
||||
public int SyncIntervalHours { get; set; } = 6;
|
||||
|
||||
/// <summary>Report watch progress back to Floatplane (not implemented yet).</summary>
|
||||
public bool SyncProgressToFloatplane { get; set; }
|
||||
}
|
||||
246
Jellyfin.Plugin.Floatplane/Configuration/configPage.html
Normal file
246
Jellyfin.Plugin.Floatplane/Configuration/configPage.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Floatplane</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="FloatplaneConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="FloatplaneConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Floatplane</h2>
|
||||
<div id="fpStatus" class="fieldDescription" style="margin-bottom:1em">Loading status…</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3 class="sectionTitle">Session</h3>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="SessionCookie">sails.sid cookie</label>
|
||||
<input is="emby-input" type="password" id="SessionCookie" autocomplete="off" />
|
||||
<div class="fieldDescription">
|
||||
Floatplane's password login needs a browser captcha, so paste the session instead:
|
||||
log in at floatplane.com, open DevTools → Application (Chrome) / Storage (Firefox) → Cookies →
|
||||
<code>https://www.floatplane.com</code> → copy the <b>Value</b> of <code>sails.sid</code>.
|
||||
Browsing works without it; playback and subscriptions need it.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="btnValidate" class="raised"><span>Validate cookie</span></button>
|
||||
<span id="validateResult" class="fieldDescription" style="margin-left:1em"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3 class="sectionTitle">Catalogue</h3>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="ExtraCreators">Extra creators (url-names, comma separated)</label>
|
||||
<input is="emby-input" type="text" id="ExtraCreators" placeholder="linustechtips, bitwit" />
|
||||
<div class="fieldDescription">Shown in addition to your subscriptions — or instead of them when no cookie is set (public listings only, no playback).</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="MaxPostsPerChannel">Videos listed per channel</label>
|
||||
<input is="emby-input" type="number" id="MaxPostsPerChannel" min="20" max="2000" step="20" />
|
||||
<div class="fieldDescription">Newest first. Floatplane pages 20 at a time, so 100 = 5 requests per channel.</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="ShowLockedPosts" />
|
||||
<span>Show posts above my tier (locked)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="CatalogCacheMinutes">Listing cache (minutes)</label>
|
||||
<input is="emby-input" type="number" id="CatalogCacheMinutes" min="1" max="1440" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="SyncIntervalHours">Automatic refresh (hours)</label>
|
||||
<input is="emby-input" type="number" id="SyncIntervalHours" min="1" max="168" />
|
||||
<div class="fieldDescription">Also available as the scheduled task "Refresh Floatplane catalogue".</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="btnRefresh" class="raised"><span>Refresh now</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3 class="sectionTitle">Playback</h3>
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredQuality">Preferred quality</label>
|
||||
<select is="emby-select" id="PreferredQuality" class="emby-select-withcolor">
|
||||
<option value="360">360p</option>
|
||||
<option value="480">480p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="2160">4K (needs the 4K tier)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">Listed first as the default version; other qualities appear as extra versions in the player.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="MaxHeight">Highest quality offered (pixels)</label>
|
||||
<input is="emby-input" type="number" id="MaxHeight" min="360" max="2160" step="1" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="DeliveryCallsPerMinute">Delivery lookups per minute</label>
|
||||
<input is="emby-input" type="number" id="DeliveryCallsPerMinute" min="1" max="10" />
|
||||
<div class="fieldDescription">Floatplane rate-limits the stream-URL endpoint to about 2 per minute. Results are cached for the token's lifetime (6 h).</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="ProxyBaseUrl">Proxy base URL (optional)</label>
|
||||
<input is="emby-input" type="text" id="ProxyBaseUrl" placeholder="http://127.0.0.1:8096" />
|
||||
<div class="fieldDescription">How the server's own ffmpeg reaches this plugin. Leave empty to use Jellyfin's local address.</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="EnableMp4Sources" />
|
||||
<span>Also offer progressive MP4 versions (experimental)</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Uses Floatplane's download delivery. Untested — may not play.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="verticalSection" style="margin-top:2em">
|
||||
<h3 class="sectionTitle">Search test (Floatplane API)</h3>
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="searchCreator">Creator</label>
|
||||
<select is="emby-select" id="searchCreator" class="emby-select-withcolor"></select>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="searchQuery">Query</label>
|
||||
<input is="emby-input" type="text" id="searchQuery" placeholder="framework" />
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="btnSearch" class="raised"><span>Search</span></button>
|
||||
</div>
|
||||
<div id="searchResults" class="fieldDescription" style="margin-top:1em; white-space:pre-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
|
||||
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "CatalogCacheMinutes", "SyncIntervalHours",
|
||||
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl"];
|
||||
var checks = ["ShowLockedPosts", "EnableMp4Sources"];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", "\"": """ }[c]; }); }
|
||||
|
||||
function loadStatus() {
|
||||
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) {
|
||||
var lines = [];
|
||||
lines.push("Plugin " + esc(s.Version));
|
||||
if (!s.HasSessionCookie) {
|
||||
lines.push("Session: <b>no cookie</b> — browsing only.");
|
||||
} else if (s.SessionState === "ok") {
|
||||
lines.push("Session: <b>ok</b>" + (s.SessionUsername ? " (" + esc(s.SessionUsername) + ")" : ""));
|
||||
} else if (s.SessionState === "invalid") {
|
||||
lines.push("Session: <b style='color:#e55'>rejected</b> — paste a fresh cookie. " + esc(s.LastError || ""));
|
||||
} else {
|
||||
lines.push("Session: cookie set, not checked yet.");
|
||||
}
|
||||
lines.push("Creators: " + s.Creators.length + (s.LastRefresh ? " (refreshed " + new Date(s.LastRefresh).toLocaleString() + ")" : " (not loaded yet)"));
|
||||
s.Creators.forEach(function (c) { lines.push(" • " + esc(c.Title) + " — " + esc(c.Channels.join(", "))); });
|
||||
lines.push("Cached post lists: " + s.CachedPostLists + ", cached deliveries: " + s.CachedDeliveries + ", generation " + s.Generation);
|
||||
el("fpStatus").innerHTML = lines.join("<br/>");
|
||||
|
||||
var sel = el("searchCreator");
|
||||
sel.innerHTML = s.Creators.map(function (c) { return "<option value='" + esc(c.Id) + "'>" + esc(c.Title) + "</option>"; }).join("");
|
||||
}).catch(function () { el("fpStatus").textContent = "Status unavailable"; });
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
fields.forEach(function (f) { el(f).value = config[f] == null ? "" : config[f]; });
|
||||
checks.forEach(function (f) { el(f).checked = !!config[f]; });
|
||||
Dashboard.hideLoadingMsg();
|
||||
loadStatus();
|
||||
});
|
||||
}
|
||||
|
||||
function saveConfig(e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
fields.forEach(function (f) {
|
||||
var v = el(f).value;
|
||||
config[f] = (el(f).type === "number") ? parseInt(v || "0", 10) : v;
|
||||
});
|
||||
checks.forEach(function (f) { config[f] = el(f).checked; });
|
||||
config.SessionCookie = (config.SessionCookie || "").replace(/^sails\.sid=/, "").trim();
|
||||
var validated = el("validateResult").getAttribute("data-username");
|
||||
if (validated && el("validateResult").getAttribute("data-cookie") === config.SessionCookie) {
|
||||
config.SessionUsername = validated;
|
||||
}
|
||||
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
// Reload creators with the new settings.
|
||||
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
||||
});
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function validate() {
|
||||
var out = el("validateResult");
|
||||
out.textContent = "Checking…";
|
||||
ApiClient.ajax({
|
||||
type: "POST",
|
||||
url: ApiClient.getUrl("Floatplane/Session/Validate"),
|
||||
data: JSON.stringify({ Cookie: el("SessionCookie").value }),
|
||||
contentType: "application/json",
|
||||
dataType: "json"
|
||||
}).then(function (r) {
|
||||
out.textContent = r.Message + (r.Subscriptions && r.Subscriptions.length ? " — " + r.Subscriptions.join("; ") : "");
|
||||
out.style.color = r.Ok ? "#4c4" : "#e55";
|
||||
if (r.Ok) {
|
||||
out.setAttribute("data-username", r.Username);
|
||||
out.setAttribute("data-cookie", r.Cookie);
|
||||
el("SessionCookie").value = r.Cookie;
|
||||
}
|
||||
}).catch(function (err) {
|
||||
out.textContent = "Request failed (" + (err && err.status ? err.status : "?") + ")";
|
||||
out.style.color = "#e55";
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
el("fpStatus").textContent = "Refreshing…";
|
||||
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
||||
}
|
||||
|
||||
function search() {
|
||||
var creator = el("searchCreator").value;
|
||||
var q = el("searchQuery").value;
|
||||
var out = el("searchResults");
|
||||
if (!creator) { out.textContent = "No creator loaded yet."; return; }
|
||||
out.textContent = "Searching…";
|
||||
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Search", { creatorId: creator, q: q, limit: 20 })).then(function (hits) {
|
||||
if (!hits.length) { out.textContent = "No results."; return; }
|
||||
out.innerHTML = hits.map(function (h) {
|
||||
var d = h.ReleaseDate ? new Date(h.ReleaseDate).toISOString().slice(0, 10) : "";
|
||||
return esc(d) + " [" + esc(h.Channel || "") + "] " + (h.IsAccessible ? "" : "🔒 ") + esc(h.Title) + " (" + Math.round(h.DurationSeconds / 60) + " min)";
|
||||
}).join("\n");
|
||||
}).catch(function () { out.textContent = "Search failed."; });
|
||||
}
|
||||
|
||||
var page = el("FloatplaneConfigPage");
|
||||
page.addEventListener("pageshow", loadConfig);
|
||||
el("FloatplaneConfigForm").addEventListener("submit", saveConfig);
|
||||
el("btnValidate").addEventListener("click", validate);
|
||||
el("btnRefresh").addEventListener("click", refresh);
|
||||
el("btnSearch").addEventListener("click", search);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
37
Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj
Normal file
37
Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj
Normal file
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Jellyfin 10.10.x = .NET 8. Keep this an AnyCPU IL assembly: no RuntimeIdentifier,
|
||||
no SelfContained, no ReadyToRun — the same DLL runs on x64 and the Pi5 (arm64). -->
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Floatplane</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Built against the oldest supported server (targetAbi 10.10.0.0 in meta.json). -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.7">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.10.7">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Jellyfin.Plugin.Floatplane.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Resources\logo.png" />
|
||||
<EmbeddedResource Include="Resources\logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
194
Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs
Normal file
194
Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs
Normal file
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Calls <c>/api/v3/delivery/info</c> sparingly: results are cached until the signed
|
||||
/// token expires and calls are throttled to the configured budget (Floatplane allows
|
||||
/// roughly two per minute and answers 429 / Cloudflare 1015 beyond that).
|
||||
/// </summary>
|
||||
public class DeliveryResolver
|
||||
{
|
||||
public const string ScenarioOnDemand = "onDemand";
|
||||
public const string ScenarioDownload = "download";
|
||||
public const string ScenarioLive = "live";
|
||||
|
||||
private readonly FloatplaneApiClient _api;
|
||||
private readonly ILogger<DeliveryResolver> _logger;
|
||||
private readonly Func<PluginConfiguration> _config;
|
||||
private readonly ConcurrentDictionary<string, CachedDelivery> _cache = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _throttle = new(1, 1);
|
||||
private DateTimeOffset _lastCall = DateTimeOffset.MinValue;
|
||||
private DateTimeOffset _blockedUntil = DateTimeOffset.MinValue;
|
||||
|
||||
public DeliveryResolver(FloatplaneApiClient api, ILogger<DeliveryResolver> logger)
|
||||
: this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
|
||||
{
|
||||
}
|
||||
|
||||
public DeliveryResolver(FloatplaneApiClient api, ILogger<DeliveryResolver> logger, Func<PluginConfiguration> config)
|
||||
{
|
||||
_api = api;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public int CachedEntries => _cache.Count;
|
||||
|
||||
public DateTimeOffset LastCall => _lastCall;
|
||||
|
||||
/// <summary>Variants for an attachment, from cache when the token is still valid.</summary>
|
||||
public async Task<IReadOnlyList<ResolvedVariant>> GetVariantsAsync(string scenario, string entityId, CancellationToken ct)
|
||||
{
|
||||
var key = scenario + "|" + entityId;
|
||||
if (_cache.TryGetValue(key, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cached.Variants;
|
||||
}
|
||||
|
||||
await _throttle.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_cache.TryGetValue(key, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cached.Variants;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_blockedUntil > now)
|
||||
{
|
||||
throw new FloatplaneRateLimitException(_blockedUntil - now, "Floatplane delivery endpoint is rate-limited; retry later");
|
||||
}
|
||||
|
||||
var perMinute = Math.Max(1, _config().DeliveryCallsPerMinute);
|
||||
var minGap = TimeSpan.FromSeconds(60.0 / perMinute);
|
||||
var wait = _lastCall + minGap - now;
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogDebug("Throttling delivery/info for {Wait}", wait);
|
||||
await Task.Delay(wait, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
DeliveryInfoModel info;
|
||||
try
|
||||
{
|
||||
_lastCall = DateTimeOffset.UtcNow;
|
||||
info = await _api.GetDeliveryInfoAsync(scenario, entityId, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (FloatplaneRateLimitException ex)
|
||||
{
|
||||
_blockedUntil = DateTimeOffset.UtcNow + ex.RetryAfter;
|
||||
throw;
|
||||
}
|
||||
|
||||
var variants = info.Resolve().ToList();
|
||||
var expires = variants
|
||||
.Select(v => TokenExpiry(v.Url))
|
||||
.Where(e => e.HasValue)
|
||||
.Select(e => e!.Value)
|
||||
.DefaultIfEmpty(DateTimeOffset.UtcNow.AddHours(1))
|
||||
.Min()
|
||||
.AddMinutes(-5);
|
||||
_cache[key] = new CachedDelivery(variants, expires);
|
||||
_logger.LogInformation(
|
||||
"delivery/info {Scenario} {Entity}: {Count} variants ({Usable} usable), valid until {Expires:u}",
|
||||
scenario,
|
||||
entityId,
|
||||
variants.Count,
|
||||
variants.Count(v => v.IsUsable),
|
||||
expires);
|
||||
return variants;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_throttle.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the variant for a requested level name ("1080"): exact level, else the best
|
||||
/// usable one not taller than requested, else the best usable one at all.
|
||||
/// </summary>
|
||||
public static ResolvedVariant? SelectVariant(IEnumerable<ResolvedVariant> variants, string requestedLevel)
|
||||
{
|
||||
var usable = variants.Where(v => v.IsUsable).ToList();
|
||||
if (usable.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var wanted = DeliveryVariant.ParseHeight(requestedLevel);
|
||||
var exact = usable.FirstOrDefault(v => string.Equals(v.Name, requestedLevel, StringComparison.OrdinalIgnoreCase)
|
||||
|| DeliveryVariant.ParseHeight(v.Name) == wanted);
|
||||
if (exact != null)
|
||||
{
|
||||
return exact;
|
||||
}
|
||||
|
||||
var notTaller = usable.Where(v => v.Height <= wanted).OrderByDescending(v => v.Height).ThenByDescending(v => v.Variant.Order).FirstOrDefault();
|
||||
return notTaller ?? usable.OrderByDescending(v => v.Height).ThenByDescending(v => v.Variant.Order).First();
|
||||
}
|
||||
|
||||
/// <summary>Reads <c>exp</c> from the JWT in a <c>?token=</c> query parameter, if any.</summary>
|
||||
public static DateTimeOffset? TokenExpiry(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var query = uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries);
|
||||
var token = query.Select(p => p.Split('=', 2)).FirstOrDefault(p => p[0] == "token" && p.Length == 2)?[1];
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = Uri.UnescapeDataString(token).Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = Base64UrlDecode(parts[1]);
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var seconds))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string s)
|
||||
{
|
||||
var padded = s.Replace('-', '+').Replace('_', '/');
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
private sealed record CachedDelivery(IReadOnlyList<ResolvedVariant> Variants, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
94
Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs
Normal file
94
Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites an HLS playlist so that (a) every media/segment URI is absolute and
|
||||
/// (b) the AES-128 key URI (which needs the Floatplane session cookie) and any nested
|
||||
/// playlist URIs point back at the plugin's proxy.
|
||||
/// </summary>
|
||||
public static partial class HlsRewriter
|
||||
{
|
||||
[GeneratedRegex("URI=\"([^\"]*)\"", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex UriAttribute();
|
||||
|
||||
/// <param name="playlist">Playlist text as served by the CDN.</param>
|
||||
/// <param name="playlistUrl">Absolute URL the playlist was fetched from (base for relative URIs).</param>
|
||||
/// <param name="mapKeyUri">Maps the absolute key URI to the proxy key URL.</param>
|
||||
/// <param name="mapPlaylistUri">Maps a nested playlist URI (master playlists) to a proxy URL; null = leave absolute.</param>
|
||||
/// <param name="mapSegmentUri">Maps a segment URI; null = leave absolute (segments need no cookie).</param>
|
||||
public static string Rewrite(
|
||||
string playlist,
|
||||
Uri playlistUrl,
|
||||
Func<string, string> mapKeyUri,
|
||||
Func<string, string>? mapPlaylistUri = null,
|
||||
Func<string, string>? mapSegmentUri = null)
|
||||
{
|
||||
var sb = new StringBuilder(playlist.Length + 512);
|
||||
var isMaster = playlist.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal);
|
||||
var nextIsVariantPlaylist = false;
|
||||
|
||||
foreach (var rawLine in playlist.Split('\n'))
|
||||
{
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
if (line.Length == 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith('#'))
|
||||
{
|
||||
if (line.StartsWith("#EXT-X-KEY", StringComparison.Ordinal) || line.StartsWith("#EXT-X-SESSION-KEY", StringComparison.Ordinal))
|
||||
{
|
||||
line = UriAttribute().Replace(line, m => "URI=\"" + mapKeyUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1);
|
||||
}
|
||||
else if (line.StartsWith("#EXT-X-MAP", StringComparison.Ordinal))
|
||||
{
|
||||
line = UriAttribute().Replace(line, m => "URI=\"" + MapSegment(Absolute(playlistUrl, m.Groups[1].Value), mapSegmentUri) + "\"", 1);
|
||||
}
|
||||
else if (line.StartsWith("#EXT-X-MEDIA", StringComparison.Ordinal) && mapPlaylistUri != null)
|
||||
{
|
||||
line = UriAttribute().Replace(line, m => "URI=\"" + mapPlaylistUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1);
|
||||
}
|
||||
else if (line.StartsWith("#EXT-X-STREAM-INF", StringComparison.Ordinal))
|
||||
{
|
||||
nextIsVariantPlaylist = true;
|
||||
}
|
||||
|
||||
sb.Append(line).Append('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
var absolute = Absolute(playlistUrl, line);
|
||||
if (isMaster && nextIsVariantPlaylist)
|
||||
{
|
||||
nextIsVariantPlaylist = false;
|
||||
sb.Append(mapPlaylistUri != null ? mapPlaylistUri(absolute) : absolute).Append('\n');
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(MapSegment(absolute, mapSegmentUri)).Append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('\n') + "\n";
|
||||
}
|
||||
|
||||
private static string MapSegment(string absolute, Func<string, string>? mapSegmentUri)
|
||||
=> mapSegmentUri != null ? mapSegmentUri(absolute) : absolute;
|
||||
|
||||
/// <summary>Resolves <paramref name="reference"/> against <paramref name="baseUrl"/> (RFC 3986).</summary>
|
||||
public static string Absolute(Uri baseUrl, string reference)
|
||||
{
|
||||
// Only http(s) is "absolute" here: on Linux Uri.TryCreate("/x.ts", Absolute) yields file:///x.ts.
|
||||
if (Api.Models.DeliveryInfoModel.IsHttpUrl(reference))
|
||||
{
|
||||
return reference;
|
||||
}
|
||||
|
||||
return new Uri(baseUrl, reference).ToString();
|
||||
}
|
||||
}
|
||||
59
Jellyfin.Plugin.Floatplane/Plugin.cs
Normal file
59
Jellyfin.Plugin.Floatplane/Plugin.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane;
|
||||
|
||||
/// <summary>
|
||||
/// Floatplane plugin — exposes Floatplane subscriptions as a Jellyfin channel.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>Plugin GUID; must match <c>build.yaml</c> / <c>meta.json</c>.</summary>
|
||||
public const string PluginGuid = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
|
||||
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Floatplane";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse(PluginGuid);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Browse and play your Floatplane subscriptions inside Jellyfin.";
|
||||
|
||||
/// <summary>Gets the current plugin instance.</summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <summary>Gets the User-Agent the plugin identifies itself with towards Floatplane.</summary>
|
||||
public static string UserAgent { get; } = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Jellyfin-Floatplane/{0} (+https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane)",
|
||||
typeof(Plugin).Assembly.GetName().Version?.ToString(3) ?? "0.0.0");
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.configPage.html",
|
||||
GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
50
Jellyfin.Plugin.Floatplane/PluginServiceRegistrator.cs
Normal file
50
Jellyfin.Plugin.Floatplane/PluginServiceRegistrator.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Channel;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the plugin's services. Found by Jellyfin through type scanning; needs a
|
||||
/// parameterless constructor. In 10.10 <c>ChannelManager</c> takes <c>IEnumerable<IChannel></c>
|
||||
/// from DI, so the channel must be registered here.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <summary>Named HttpClient for Floatplane API + CDN traffic.</summary>
|
||||
public const string HttpClientName = "Floatplane";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection
|
||||
.AddHttpClient(HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(Plugin.UserAgent);
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
// The session cookie is attached per request; no cookie jar, no redirects
|
||||
// (a 302 from the CDN would otherwise be followed with the cookie attached).
|
||||
UseCookies = false,
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
});
|
||||
|
||||
serviceCollection.AddSingleton<FloatplaneApiClient>();
|
||||
serviceCollection.AddSingleton<CatalogService>();
|
||||
serviceCollection.AddSingleton<DeliveryResolver>();
|
||||
|
||||
// One instance: the status endpoint and the sync task must see the same caches
|
||||
// as the channel Jellyfin serves from.
|
||||
serviceCollection.AddSingleton<FloatplaneChannel>();
|
||||
serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>());
|
||||
}
|
||||
}
|
||||
BIN
Jellyfin.Plugin.Floatplane/Resources/logo.png
Normal file
BIN
Jellyfin.Plugin.Floatplane/Resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
65
Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs
Normal file
65
Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Channel;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task: refresh creators/channels and warm the newest posts, then bump the
|
||||
/// channel cache key so Jellyfin re-lists on the next browse. Found by type scanning.
|
||||
/// </summary>
|
||||
public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
|
||||
{
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly ILogger<FloatplaneSyncTask> _logger;
|
||||
|
||||
public FloatplaneSyncTask(CatalogService catalog, ILogger<FloatplaneSyncTask> logger)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Refresh Floatplane catalogue";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "FloatplaneSync";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Reloads subscribed creators, their channels and the newest videos from Floatplane.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Floatplane";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsHidden => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLogged => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Floatplane sync started");
|
||||
await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
var hours = Math.Clamp(Plugin.Instance?.Configuration.SyncIntervalHours ?? 6, 1, 168);
|
||||
return
|
||||
[
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerStartup },
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(hours).Ticks }
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user