v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs
Some checks failed
release / build-release (push) Has been cancelled
- per-creator "All videos" folder (release order across sub-channels); sub-channel copies use cpost: ids and UserDataMirror keeps played/resume state in sync - SortNameUpdater forces release-order sort names so every client lists newest first by default - official Floatplane icon as channel image (Primary only — a channel Thumb hid video thumbnails in Continue Watching) and as 16:9 plugin thumbnail; one-time image replacement on upgrade - OAuth device-flow login (FloatplaneAuthService + settings UI); needs a Floatplane client id; Turnstile verified domain-bound so password login inside Jellyfin is impossible - generic install.sh (linuxserver / official image / native), thumb.png in package + manifest - README rewritten for a general audience, doc/usage.md with screenshots, 60 tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@@ -6,7 +6,7 @@ name: release
|
|||||||
# - tagg vX.Y.Z -> versionerad release (plugin-version X.Y.Z.0)
|
# - tagg vX.Y.Z -> versionerad release (plugin-version X.Y.Z.0)
|
||||||
# - workflow_dispatch -> valfri tag (tom = latest)
|
# - workflow_dispatch -> valfri tag (tom = latest)
|
||||||
# Assets: floatplane_<ver>.zip (+.md5), manifest.json (Jellyfins plugin-
|
# Assets: floatplane_<ver>.zip (+.md5), manifest.json (Jellyfins plugin-
|
||||||
# repository-format) och install-pi5.sh. manifest.json laddas ALLTID upp
|
# repository-format) och install.sh. manifest.json laddas ALLTID upp
|
||||||
# till "latest" sa att URL:en nedan alltid pekar pa nyaste versionen:
|
# till "latest" sa att URL:en nedan alltid pekar pa nyaste versionen:
|
||||||
# https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
|
# https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
|
||||||
#
|
#
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
Sedan Plugins -> Catalog -> Floatplane -> Install, starta om Jellyfin.
|
Sedan Plugins -> Catalog -> Floatplane -> Install, starta om Jellyfin.
|
||||||
|
|
||||||
Manuell installation pa en host med linuxserver/jellyfin-containern:
|
Manuell installation pa en host med linuxserver/jellyfin-containern:
|
||||||
curl -fsSL $REPO_URL/releases/download/$TAG/install-pi5.sh | sh
|
curl -fsSL $REPO_URL/releases/download/$TAG/install.sh | sh
|
||||||
(TAG=... JELLYFIN_CONFIG_DIR=... JELLYFIN_CONTAINER=... och --dry-run stods, se skriptet.)
|
(TAG=... JELLYFIN_CONFIG_DIR=... JELLYFIN_CONTAINER=... och --dry-run stods, se skriptet.)
|
||||||
|
|
||||||
Efter installation: Dashboard -> Plugins -> Floatplane -> klistra in sails.sid-cookien."
|
Efter installation: Dashboard -> Plugins -> Floatplane -> klistra in sails.sid-cookien."
|
||||||
@@ -159,7 +159,8 @@ jobs:
|
|||||||
upload "$rid" "$ZIP" "build/$ZIP"
|
upload "$rid" "$ZIP" "build/$ZIP"
|
||||||
upload "$rid" "$ZIP.md5" "build/$ZIP.md5"
|
upload "$rid" "$ZIP.md5" "build/$ZIP.md5"
|
||||||
upload "$rid" "manifest.json" "build/manifest.json"
|
upload "$rid" "manifest.json" "build/manifest.json"
|
||||||
upload "$rid" "install-pi5.sh" "install-pi5.sh"
|
upload "$rid" "install.sh" "install.sh"
|
||||||
|
upload "$rid" "thumb.png" "build/thumb.png"
|
||||||
echo "klart: https://gitea.brasse-pc.eu/$REPO/releases/tag/$TAG"
|
echo "klart: https://gitea.brasse-pc.eu/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
# --- manifest.json aven till "latest" vid taggad release --------------
|
# --- manifest.json aven till "latest" vid taggad release --------------
|
||||||
@@ -175,7 +176,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
if [ -n "$lid" ] && [ "$lid" != "null" ]; then
|
if [ -n "$lid" ] && [ "$lid" != "null" ]; then
|
||||||
upload "$lid" "manifest.json" "build/manifest.json.keep"
|
upload "$lid" "manifest.json" "build/manifest.json.keep"
|
||||||
upload "$lid" "install-pi5.sh" "install-pi5.sh"
|
upload "$lid" "install.sh" "install.sh"
|
||||||
|
upload "$lid" "thumb.png" "build/thumb.png"
|
||||||
echo "manifest.json uppdaterat pa latest: $REPO_URL/releases/download/latest/manifest.json"
|
echo "manifest.json uppdaterat pa latest: $REPO_URL/releases/download/latest/manifest.json"
|
||||||
else
|
else
|
||||||
echo "::warning::kunde inte hitta/skapa latest-releasen for manifest.json"
|
echo "::warning::kunde inte hitta/skapa latest-releasen for manifest.json"
|
||||||
|
|||||||
55
Jellyfin.Plugin.Floatplane.Tests/AllVideosTests.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||||
|
using Jellyfin.Plugin.Floatplane.Channel;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||||
|
|
||||||
|
public class AllVideosTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("post:SSvhppI28t", "SSvhppI28t")]
|
||||||
|
[InlineData("cpost:64135da7ce81077a8480c679:SSvhppI28t", "SSvhppI28t")]
|
||||||
|
[InlineData("creator:59f94c0bdd241b70349eb72b", null)]
|
||||||
|
[InlineData("all:59f94c0bdd241b70349eb72b", null)]
|
||||||
|
[InlineData("channel:a:b", null)]
|
||||||
|
[InlineData("cpost:nochannel", null)]
|
||||||
|
[InlineData("", null)]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
public void PostIdOf_ParsesBothItemIdForms(string? externalId, string? expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, FloatplaneChannel.PostIdOf(externalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToItem_UsesCanonicalIdByDefault_AndChannelPrefixWhenAsked()
|
||||||
|
{
|
||||||
|
var posts = Fixture.Json<List<PostModel>>("content-creator.json");
|
||||||
|
var post = posts[0];
|
||||||
|
|
||||||
|
var canonical = FloatplaneChannel.ToItem(post, null);
|
||||||
|
var inChannel = FloatplaneChannel.ToItem(post, null, true, "cpost:" + post.Channel!.Id + ":");
|
||||||
|
|
||||||
|
Assert.Equal("post:" + post.Id, canonical.Id);
|
||||||
|
Assert.Equal("cpost:" + post.Channel.Id + ":" + post.Id, inChannel.Id);
|
||||||
|
Assert.Equal(canonical.Name, inChannel.Name);
|
||||||
|
Assert.Equal(post.Id, FloatplaneChannel.PostIdOf(canonical.Id));
|
||||||
|
Assert.Equal(post.Id, FloatplaneChannel.PostIdOf(inChannel.Id));
|
||||||
|
Assert.NotEqual(FloatplaneChannel.DeterministicGuid(canonical.Id), FloatplaneChannel.DeterministicGuid(inChannel.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SortNameTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewerReleaseSortsFirstUnderAscendingNameOrder()
|
||||||
|
{
|
||||||
|
var older = SortNameUpdater.SortNameFor(new System.DateTime(2026, 10, 1, 0, 0, 0, System.DateTimeKind.Utc));
|
||||||
|
var newer = SortNameUpdater.SortNameFor(new System.DateTime(2026, 10, 24, 18, 24, 0, System.DateTimeKind.Utc));
|
||||||
|
Assert.Equal(13, older.Length);
|
||||||
|
Assert.Equal(13, newer.Length);
|
||||||
|
Assert.True(string.CompareOrdinal(newer, older) < 0, "newer must sort before older");
|
||||||
|
Assert.Equal(13, SortNameUpdater.SortNameFor(null).Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,18 +18,90 @@ namespace Jellyfin.Plugin.Floatplane.Api;
|
|||||||
public class FloatplaneAdminController : ControllerBase
|
public class FloatplaneAdminController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly FloatplaneApiClient _api;
|
private readonly FloatplaneApiClient _api;
|
||||||
|
private readonly FloatplaneAuthService _auth;
|
||||||
private readonly CatalogService _catalog;
|
private readonly CatalogService _catalog;
|
||||||
private readonly DeliveryResolver _delivery;
|
private readonly DeliveryResolver _delivery;
|
||||||
private readonly ILogger<FloatplaneAdminController> _logger;
|
private readonly ILogger<FloatplaneAdminController> _logger;
|
||||||
|
|
||||||
public FloatplaneAdminController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneAdminController> logger)
|
public FloatplaneAdminController(FloatplaneApiClient api, FloatplaneAuthService auth, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneAdminController> logger)
|
||||||
{
|
{
|
||||||
_api = api;
|
_api = api;
|
||||||
|
_auth = auth;
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
_delivery = delivery;
|
_delivery = delivery;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── OAuth device flow ─────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Starts an OAuth device login with the given client id (saved to the configuration).</summary>
|
||||||
|
[HttpPost("OAuth/Start")]
|
||||||
|
public async Task<ActionResult<DeviceLoginStart>> StartOAuth([FromBody] OAuthStartRequest request)
|
||||||
|
{
|
||||||
|
var clientId = request.ClientId?.Trim() ?? string.Empty;
|
||||||
|
if (clientId.Length == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("Client id is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var start = await _auth.StartDeviceLoginAsync(clientId, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
|
var cfg = Plugin.Instance?.Configuration;
|
||||||
|
if (cfg != null && cfg.OAuthClientId != clientId)
|
||||||
|
{
|
||||||
|
cfg.OAuthClientId = clientId;
|
||||||
|
Plugin.Instance!.SaveConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
start.DeviceCode = string.Empty; // never hand the device code to the browser
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
catch (FloatplaneApiException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("OAuth device start failed: {Error} {Message}", ex.ErrorName, ex.Message);
|
||||||
|
return StatusCode(502, $"{ex.ErrorName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Polls the pending device login once.</summary>
|
||||||
|
[HttpGet("OAuth/Poll")]
|
||||||
|
public async Task<ActionResult<DeviceLoginStatus>> PollOAuth()
|
||||||
|
{
|
||||||
|
var status = await _auth.PollDeviceLoginAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
|
if (status.State == "done")
|
||||||
|
{
|
||||||
|
_catalog.Invalidate();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var self = await _api.GetSelfAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
|
var cfg = Plugin.Instance?.Configuration;
|
||||||
|
if (cfg != null)
|
||||||
|
{
|
||||||
|
cfg.SessionUsername = self.Username;
|
||||||
|
Plugin.Instance!.SaveConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
status.Message = "Logged in as " + self.Username;
|
||||||
|
}
|
||||||
|
catch (FloatplaneApiException ex)
|
||||||
|
{
|
||||||
|
status.Message = "Logged in, but user lookup failed: " + ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops the stored OAuth tokens.</summary>
|
||||||
|
[HttpPost("OAuth/Logout")]
|
||||||
|
public ActionResult<PluginStatus> LogoutOAuth()
|
||||||
|
{
|
||||||
|
FloatplaneAuthService.Logout();
|
||||||
|
_catalog.Invalidate();
|
||||||
|
return GetStatus();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Validates a session cookie (without saving it) and lists the subscriptions it has.</summary>
|
/// <summary>Validates a session cookie (without saving it) and lists the subscriptions it has.</summary>
|
||||||
[HttpPost("Session/Validate")]
|
[HttpPost("Session/Validate")]
|
||||||
public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request)
|
public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request)
|
||||||
@@ -78,6 +150,8 @@ public class FloatplaneAdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
|
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
|
||||||
HasSessionCookie = _api.HasSession,
|
HasSessionCookie = _api.HasSession,
|
||||||
|
SessionKind = _api.SessionKind,
|
||||||
|
OAuthClientId = Plugin.Instance?.Configuration.OAuthClientId ?? string.Empty,
|
||||||
SessionState = _catalog.SessionState ?? "unknown",
|
SessionState = _catalog.SessionState ?? "unknown",
|
||||||
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
|
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
|
||||||
LastError = _catalog.LastError,
|
LastError = _catalog.LastError,
|
||||||
@@ -131,6 +205,11 @@ public class FloatplaneAdminController : ControllerBase
|
|||||||
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
|
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class OAuthStartRequest
|
||||||
|
{
|
||||||
|
public string? ClientId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class SessionValidationRequest
|
public class SessionValidationRequest
|
||||||
{
|
{
|
||||||
public string? Cookie { get; set; }
|
public string? Cookie { get; set; }
|
||||||
@@ -155,6 +234,11 @@ public class PluginStatus
|
|||||||
|
|
||||||
public bool HasSessionCookie { get; set; }
|
public bool HasSessionCookie { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"oauth", "cookie" or "none".</summary>
|
||||||
|
public string SessionKind { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string OAuthClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string SessionState { get; set; } = string.Empty;
|
public string SessionState { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string SessionUsername { get; set; } = string.Empty;
|
public string SessionUsername { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -27,13 +27,15 @@ public class FloatplaneApiClient
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ILogger<FloatplaneApiClient> _logger;
|
private readonly ILogger<FloatplaneApiClient> _logger;
|
||||||
private readonly Func<PluginConfiguration> _config;
|
private readonly Func<PluginConfiguration> _config;
|
||||||
|
private readonly FloatplaneAuthService? _auth;
|
||||||
|
|
||||||
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger)
|
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, FloatplaneAuthService auth)
|
||||||
: this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
|
: this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
|
||||||
{
|
{
|
||||||
|
_auth = auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Constructor for tests: configuration is supplied explicitly.</summary>
|
/// <summary>Constructor for tests: configuration is supplied explicitly, no OAuth.</summary>
|
||||||
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
|
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
@@ -41,8 +43,11 @@ public class FloatplaneApiClient
|
|||||||
_config = config;
|
_config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when a session cookie is configured (not necessarily valid).</summary>
|
/// <summary>True when a session (cookie or OAuth refresh token) is configured — not necessarily valid.</summary>
|
||||||
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie);
|
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie) || !string.IsNullOrEmpty(_config().OAuthRefreshToken);
|
||||||
|
|
||||||
|
/// <summary>"oauth", "cookie" or "none".</summary>
|
||||||
|
public string SessionKind => !string.IsNullOrEmpty(_config().OAuthRefreshToken) ? "oauth" : !string.IsNullOrWhiteSpace(_config().SessionCookie) ? "cookie" : "none";
|
||||||
|
|
||||||
// ───────────────────────────── auth / user ─────────────────────────────
|
// ───────────────────────────── auth / user ─────────────────────────────
|
||||||
|
|
||||||
@@ -177,7 +182,7 @@ public class FloatplaneApiClient
|
|||||||
public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct)
|
public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds });
|
var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds });
|
||||||
using var request = BuildRequest(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null);
|
using var request = await BuildRequestAsync(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null, ct).ConfigureAwait(false);
|
||||||
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||||
@@ -205,7 +210,7 @@ public class FloatplaneApiClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<byte[]> GetWatchKeyAsync(string keyUrl, CancellationToken ct)
|
public async Task<byte[]> GetWatchKeyAsync(string keyUrl, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var request = BuildRequest(HttpMethod.Get, keyUrl, null, null);
|
using var request = await BuildRequestAsync(HttpMethod.Get, keyUrl, null, null, ct).ConfigureAwait(false);
|
||||||
request.Headers.Referrer = new Uri(BaseUrl + "/");
|
request.Headers.Referrer = new Uri(BaseUrl + "/");
|
||||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||||
@@ -228,6 +233,23 @@ public class FloatplaneApiClient
|
|||||||
|
|
||||||
// ───────────────────────────── plumbing ─────────────────────────────
|
// ───────────────────────────── plumbing ─────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Builds a request with auth: explicit cookie > OAuth bearer > configured cookie.</summary>
|
||||||
|
internal async Task<HttpRequestMessage> BuildRequestAsync(HttpMethod method, string url, Dictionary<string, string>? query, string? cookieOverride, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var request = BuildRequest(method, url, query, cookieOverride);
|
||||||
|
if (cookieOverride == null && _auth != null && !string.IsNullOrEmpty(_config().OAuthRefreshToken))
|
||||||
|
{
|
||||||
|
var token = await _auth.GetAccessTokenAsync(ct).ConfigureAwait(false);
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
request.Headers.Remove("Cookie");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
internal HttpRequestMessage BuildRequest(HttpMethod method, string url, Dictionary<string, string>? query, string? cookieOverride)
|
internal HttpRequestMessage BuildRequest(HttpMethod method, string url, Dictionary<string, string>? query, string? cookieOverride)
|
||||||
{
|
{
|
||||||
if (query is { Count: > 0 })
|
if (query is { Count: > 0 })
|
||||||
@@ -262,7 +284,7 @@ public class FloatplaneApiClient
|
|||||||
|
|
||||||
private async Task<T> GetAsync<T>(string path, Dictionary<string, string>? query, CancellationToken ct, string? cookieOverride = null)
|
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 request = await BuildRequestAsync(HttpMethod.Get, BaseUrl + path, query, cookieOverride, ct).ConfigureAwait(false);
|
||||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||||
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
|||||||
280
Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Floatplane.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth 2.0 device-code login against Floatplane's Keycloak
|
||||||
|
/// (<c>auth.floatplane.com</c>), the way Hydravion / Wasserflug / floatplane-downloader
|
||||||
|
/// log in. It needs a <c>client_id</c> registered by Floatplane; without one the server
|
||||||
|
/// answers <c>invalid_client</c>. Tokens are kept in the plugin configuration. DPoP-bound
|
||||||
|
/// clients are not supported (plain Bearer only).
|
||||||
|
/// </summary>
|
||||||
|
public class FloatplaneAuthService
|
||||||
|
{
|
||||||
|
public const string Issuer = "https://auth.floatplane.com/realms/floatplane";
|
||||||
|
private const string DeviceEndpoint = Issuer + "/protocol/openid-connect/auth/device";
|
||||||
|
private const string TokenEndpoint = Issuer + "/protocol/openid-connect/token";
|
||||||
|
private const string Scope = "openid profile email offline_access";
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<FloatplaneAuthService> _logger;
|
||||||
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||||
|
private PendingDeviceLogin? _pending;
|
||||||
|
|
||||||
|
public FloatplaneAuthService(IHttpClientFactory httpClientFactory, ILogger<FloatplaneAuthService> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
|
||||||
|
/// <summary>True when a refresh token is stored (OAuth login completed at some point).</summary>
|
||||||
|
public static bool HasOAuthSession => !string.IsNullOrEmpty(Config.OAuthRefreshToken);
|
||||||
|
|
||||||
|
/// <summary>Starts a device login: returns the code and URL the user must open.</summary>
|
||||||
|
public async Task<DeviceLoginStart> StartDeviceLoginAsync(string clientId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var form = new Dictionary<string, string> { ["client_id"] = clientId, ["scope"] = Scope };
|
||||||
|
using var response = await PostFormAsync(DeviceEndpoint, form, ct).ConfigureAwait(false);
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var err = Parse<OAuthError>(json);
|
||||||
|
throw new FloatplaneApiException(response.StatusCode, err?.Error, err?.ErrorDescription ?? "Device authorization failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = Parse<DeviceLoginStart>(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected device authorization response");
|
||||||
|
_pending = new PendingDeviceLogin(clientId, start.DeviceCode, DateTimeOffset.UtcNow.AddSeconds(start.ExpiresIn), Math.Max(5, start.Interval));
|
||||||
|
_logger.LogInformation("Floatplane device login started; user code {Code}", start.UserCode);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One poll of the token endpoint; "pending" until the user has approved.</summary>
|
||||||
|
public async Task<DeviceLoginStatus> PollDeviceLoginAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pending = _pending;
|
||||||
|
if (pending == null)
|
||||||
|
{
|
||||||
|
return new DeviceLoginStatus { State = "none", Message = "No login in progress" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.ExpiresAt < DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
_pending = null;
|
||||||
|
return new DeviceLoginStatus { State = "expired", Message = "The code expired; start again" };
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
["client_id"] = pending.ClientId,
|
||||||
|
["device_code"] = pending.DeviceCode
|
||||||
|
};
|
||||||
|
using var response = await PostFormAsync(TokenEndpoint, form, ct).ConfigureAwait(false);
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var tokens = Parse<TokenResponse>(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected token response");
|
||||||
|
StoreTokens(tokens, pending.ClientId);
|
||||||
|
_pending = null;
|
||||||
|
return new DeviceLoginStatus { State = "done", Message = "Logged in" };
|
||||||
|
}
|
||||||
|
|
||||||
|
var err = Parse<OAuthError>(json);
|
||||||
|
switch (err?.Error)
|
||||||
|
{
|
||||||
|
case "authorization_pending":
|
||||||
|
return new DeviceLoginStatus { State = "pending", Message = "Waiting for approval…", Interval = pending.Interval };
|
||||||
|
case "slow_down":
|
||||||
|
pending.Interval += 5;
|
||||||
|
return new DeviceLoginStatus { State = "pending", Message = "Waiting (slower)…", Interval = pending.Interval };
|
||||||
|
default:
|
||||||
|
_pending = null;
|
||||||
|
return new DeviceLoginStatus { State = "error", Message = err?.ErrorDescription ?? err?.Error ?? "Login failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the OAuth tokens (local only; Floatplane keeps the session until it expires).</summary>
|
||||||
|
public static void Logout()
|
||||||
|
{
|
||||||
|
var cfg = Config;
|
||||||
|
cfg.OAuthAccessToken = string.Empty;
|
||||||
|
cfg.OAuthRefreshToken = string.Empty;
|
||||||
|
cfg.OAuthAccessTokenExpiry = 0;
|
||||||
|
Plugin.Instance?.SaveConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access token for API calls, refreshed when it has less than a minute left. Never
|
||||||
|
/// refresh per request: ~40 refreshes in a row trip Cloudflare's 429 on auth.floatplane.com.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetAccessTokenAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = Config;
|
||||||
|
if (string.IsNullOrEmpty(cfg.OAuthRefreshToken))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiry = DateTimeOffset.FromUnixTimeSeconds(cfg.OAuthAccessTokenExpiry);
|
||||||
|
if (!string.IsNullOrEmpty(cfg.OAuthAccessToken) && expiry > DateTimeOffset.UtcNow.AddSeconds(60))
|
||||||
|
{
|
||||||
|
return cfg.OAuthAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _refreshLock.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cfg = Config;
|
||||||
|
expiry = DateTimeOffset.FromUnixTimeSeconds(cfg.OAuthAccessTokenExpiry);
|
||||||
|
if (!string.IsNullOrEmpty(cfg.OAuthAccessToken) && expiry > DateTimeOffset.UtcNow.AddSeconds(60))
|
||||||
|
{
|
||||||
|
return cfg.OAuthAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "refresh_token",
|
||||||
|
["client_id"] = cfg.OAuthClientId,
|
||||||
|
["refresh_token"] = cfg.OAuthRefreshToken
|
||||||
|
};
|
||||||
|
using var response = await PostFormAsync(TokenEndpoint, form, ct).ConfigureAwait(false);
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var err = Parse<OAuthError>(json);
|
||||||
|
_logger.LogWarning("Floatplane token refresh failed: {Error} {Description}", err?.Error, err?.ErrorDescription);
|
||||||
|
if (err?.Error == "invalid_grant")
|
||||||
|
{
|
||||||
|
Logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FloatplaneAuthException(response.StatusCode, err?.Error ?? "refreshFailed", err?.ErrorDescription ?? "Token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = Parse<TokenResponse>(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected token response");
|
||||||
|
StoreTokens(tokens, cfg.OAuthClientId);
|
||||||
|
return tokens.AccessToken;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StoreTokens(TokenResponse tokens, string clientId)
|
||||||
|
{
|
||||||
|
var cfg = Config;
|
||||||
|
cfg.OAuthClientId = clientId;
|
||||||
|
cfg.OAuthAccessToken = tokens.AccessToken ?? string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(tokens.RefreshToken))
|
||||||
|
{
|
||||||
|
cfg.OAuthRefreshToken = tokens.RefreshToken; // rotated on every refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.OAuthAccessTokenExpiry = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, tokens.ExpiresIn)).ToUnixTimeSeconds();
|
||||||
|
Plugin.Instance?.SaveConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> PostFormAsync(string url, Dictionary<string, string> form, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(form) };
|
||||||
|
return await client.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? Parse<T>(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PendingDeviceLogin
|
||||||
|
{
|
||||||
|
public PendingDeviceLogin(string clientId, string deviceCode, DateTimeOffset expiresAt, int interval)
|
||||||
|
{
|
||||||
|
ClientId = clientId;
|
||||||
|
DeviceCode = deviceCode;
|
||||||
|
ExpiresAt = expiresAt;
|
||||||
|
Interval = interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ClientId { get; }
|
||||||
|
|
||||||
|
public string DeviceCode { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset ExpiresAt { get; }
|
||||||
|
|
||||||
|
public int Interval { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DeviceLoginStart
|
||||||
|
{
|
||||||
|
[JsonPropertyName("device_code")]
|
||||||
|
public string DeviceCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("user_code")]
|
||||||
|
public string UserCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("verification_uri")]
|
||||||
|
public string VerificationUri { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("verification_uri_complete")]
|
||||||
|
public string? VerificationUriComplete { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; } = 600;
|
||||||
|
|
||||||
|
[JsonPropertyName("interval")]
|
||||||
|
public int Interval { get; set; } = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DeviceLoginStatus
|
||||||
|
{
|
||||||
|
public string State { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
public int Interval { get; set; } = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("token_type")]
|
||||||
|
public string? TokenType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OAuthError
|
||||||
|
{
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("error_description")]
|
||||||
|
public string? ErrorDescription { get; set; }
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ public class CatalogService
|
|||||||
|
|
||||||
public DateTimeOffset LastRefresh => _creatorsFetched;
|
public DateTimeOffset LastRefresh => _creatorsFetched;
|
||||||
|
|
||||||
|
/// <summary>"oauth", "cookie" or "none".</summary>
|
||||||
|
public string SessionKind => _api.SessionKind;
|
||||||
|
|
||||||
/// <summary>Session state as last observed (null = not checked yet).</summary>
|
/// <summary>Session state as last observed (null = not checked yet).</summary>
|
||||||
public string? SessionState { get; private set; }
|
public string? SessionState { get; private set; }
|
||||||
|
|
||||||
@@ -83,7 +86,16 @@ public class CatalogService
|
|||||||
var done = 0;
|
var done = 0;
|
||||||
foreach (var creator in creators)
|
foreach (var creator in creators)
|
||||||
{
|
{
|
||||||
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { string.Empty };
|
var channelIds = creator.Channels.Select(c => c.Id).ToList();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await GetAllPostsAsync(creator.Id, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not warm all-videos list for {Creator}", creator.Urlname);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var channelId in channelIds)
|
foreach (var channelId in channelIds)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
@@ -240,6 +252,22 @@ public class CatalogService
|
|||||||
return posts;
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Newest video posts across all of a creator's channels (the "All videos" folder).</summary>
|
||||||
|
public async Task<IReadOnlyList<PostModel>> GetAllPostsAsync(string creatorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var key = creatorId + "|*";
|
||||||
|
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().MaxPostsAllVideos, 20, 5000);
|
||||||
|
var posts = await FetchPostsAsync(creatorId, string.Empty, 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>
|
/// <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)
|
public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,18 +32,22 @@ namespace Jellyfin.Plugin.Floatplane.Channel;
|
|||||||
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
|
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
|
||||||
{
|
{
|
||||||
private const string CreatorPrefix = "creator:";
|
private const string CreatorPrefix = "creator:";
|
||||||
|
private const string AllPrefix = "all:";
|
||||||
private const string ChannelPrefix = "channel:";
|
private const string ChannelPrefix = "channel:";
|
||||||
private const string PostPrefix = "post:";
|
private const string PostPrefix = "post:";
|
||||||
|
private const string ChannelPostPrefix = "cpost:";
|
||||||
|
|
||||||
private readonly CatalogService _catalog;
|
private readonly CatalogService _catalog;
|
||||||
private readonly IServerApplicationHost _appHost;
|
private readonly IServerApplicationHost _appHost;
|
||||||
|
private readonly SortNameUpdater _sortNames;
|
||||||
private readonly ILogger<FloatplaneChannel> _logger;
|
private readonly ILogger<FloatplaneChannel> _logger;
|
||||||
private readonly Func<PluginConfiguration> _config;
|
private readonly Func<PluginConfiguration> _config;
|
||||||
|
|
||||||
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger)
|
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, SortNameUpdater sortNames, ILogger<FloatplaneChannel> logger)
|
||||||
{
|
{
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
|
_sortNames = sortNames;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
}
|
}
|
||||||
@@ -82,7 +86,9 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb];
|
// Primary only: a channel Thumb would be inherited by every video card in "Continue
|
||||||
|
// watching" (the web client prefers parent thumbs there), hiding the video thumbnails.
|
||||||
|
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||||
@@ -111,6 +117,10 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
|||||||
{
|
{
|
||||||
items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
else if (folderId.StartsWith(AllPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
items = await GetAllVideoItemsAsync(folderId[AllPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
|
else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var rest = folderId[ChannelPrefix.Length..];
|
var rest = folderId[ChannelPrefix.Length..];
|
||||||
@@ -125,6 +135,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
|||||||
items = new List<ChannelItemInfo>();
|
items = new List<ChannelItemInfo>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.Any(i => i.Type == ChannelItemType.Media))
|
||||||
|
{
|
||||||
|
// Jellyfin creates the DB entities after this returns; fix their sort names then.
|
||||||
|
_sortNames.ScheduleSoon();
|
||||||
|
}
|
||||||
|
|
||||||
items = ApplySort(items, query);
|
items = ApplySort(items, query);
|
||||||
var total = items.Count;
|
var total = items.Count;
|
||||||
if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
|
if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
|
||||||
@@ -164,12 +180,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
|
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal))
|
var postId = PostIdOf(id);
|
||||||
|
if (postId is null)
|
||||||
{
|
{
|
||||||
return Array.Empty<MediaSourceInfo>();
|
return Array.Empty<MediaSourceInfo>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var postId = id[PostPrefix.Length..];
|
|
||||||
PostModel post;
|
PostModel post;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -259,10 +275,23 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
|||||||
|
|
||||||
if (creator.Channels.Count == 0)
|
if (creator.Channels.Count == 0)
|
||||||
{
|
{
|
||||||
return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false);
|
return await GetAllVideoItemsAsync(creator.Id, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return creator.Channels.Select(ch => new ChannelItemInfo
|
var items = new List<ChannelItemInfo>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = AllPrefix + creator.Id,
|
||||||
|
Name = "All videos",
|
||||||
|
Type = ChannelItemType.Folder,
|
||||||
|
FolderType = ChannelFolderType.Container,
|
||||||
|
Overview = "Every video from " + creator.Title + " across all its channels, newest first.",
|
||||||
|
ImageUrl = creator.Card?.BestPath(1200) ?? creator.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
||||||
|
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
items.AddRange(creator.Channels.Select(ch => new ChannelItemInfo
|
||||||
{
|
{
|
||||||
Id = ChannelPrefix + creator.Id + ":" + ch.Id,
|
Id = ChannelPrefix + creator.Id + ":" + ch.Id,
|
||||||
Name = ch.Title,
|
Name = ch.Title,
|
||||||
@@ -271,25 +300,62 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
|||||||
Overview = ch.About,
|
Overview = ch.About,
|
||||||
ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
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
|
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname
|
||||||
}).ToList();
|
}));
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>"All videos": every video post of the creator, newest first (canonical <c>post:</c> ids).</summary>
|
||||||
|
private async Task<List<ChannelItemInfo>> GetAllVideoItemsAsync(string creatorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var creator = _catalog.FindCreator(creatorId);
|
||||||
|
var posts = await _catalog.GetAllPostsAsync(creatorId, ct).ConfigureAwait(false);
|
||||||
|
var markLocked = _catalog.HasSession;
|
||||||
|
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sub-channel folder: the same posts under <c>cpost:</c> ids (one parent per item in Jellyfin).</summary>
|
||||||
private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct)
|
private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var creator = _catalog.FindCreator(creatorId);
|
var creator = _catalog.FindCreator(creatorId);
|
||||||
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
|
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
|
||||||
// Anonymous listings flag everything as inaccessible; only mark locks when logged in.
|
// Anonymous listings flag everything as inaccessible; only mark locks when logged in.
|
||||||
var markLocked = _catalog.HasSession;
|
var markLocked = _catalog.HasSession;
|
||||||
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
return posts.Select(p => ToItem(p, creator, markLocked, ChannelPostPrefix + channelId + ":")).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the Floatplane post id from a channel item id (<c>post:<id></c> or
|
||||||
|
/// <c>cpost:<channelId>:<id></c>); null for folders / foreign ids.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? PostIdOf(string? externalId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalId.StartsWith(PostPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return externalId[PostPrefix.Length..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalId.StartsWith(ChannelPostPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var rest = externalId[ChannelPostPrefix.Length..];
|
||||||
|
var sep = rest.IndexOf(':', StringComparison.Ordinal);
|
||||||
|
return sep > 0 && sep < rest.Length - 1 ? rest[(sep + 1)..] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
|
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
|
||||||
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true)
|
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true, string idPrefix = PostPrefix)
|
||||||
{
|
{
|
||||||
var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
|
var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
|
||||||
var item = new ChannelItemInfo
|
var item = new ChannelItemInfo
|
||||||
{
|
{
|
||||||
Id = PostPrefix + post.Id,
|
Id = idPrefix + post.Id,
|
||||||
Name = name,
|
Name = name,
|
||||||
Type = ChannelItemType.Media,
|
Type = ChannelItemType.Media,
|
||||||
MediaType = ChannelMediaType.Video,
|
MediaType = ChannelMediaType.Video,
|
||||||
|
|||||||
120
Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every Jellyfin client lists a channel folder sorted by name by default, and the web
|
||||||
|
/// client stores a sort choice per folder. To get release order without touching every
|
||||||
|
/// folder, each video's <c>ForcedSortName</c> is set to its release time reversed
|
||||||
|
/// (<c>(10^13 - unixMs)</c>, zero-padded), so "Name ascending" = newest first.
|
||||||
|
/// Runs a few seconds after a listing (the items only exist in the database after
|
||||||
|
/// Jellyfin has synced them) and during the scheduled refresh.
|
||||||
|
/// </summary>
|
||||||
|
public class SortNameUpdater
|
||||||
|
{
|
||||||
|
private const long Epoch = 10_000_000_000_000; // > any unix ms this side of year 2286
|
||||||
|
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILogger<SortNameUpdater> _logger;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
private int _scheduled;
|
||||||
|
|
||||||
|
public SortNameUpdater(ILibraryManager libraryManager, ILogger<SortNameUpdater> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sort name for a release date: newest first under ascending name order.</summary>
|
||||||
|
public static string SortNameFor(DateTime? releaseDate)
|
||||||
|
{
|
||||||
|
var ms = releaseDate.HasValue ? new DateTimeOffset(DateTime.SpecifyKind(releaseDate.Value, DateTimeKind.Utc)).ToUnixTimeMilliseconds() : 0;
|
||||||
|
return (Epoch - 1 - Math.Clamp(ms, 0, Epoch - 1)).ToString("D13", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Schedules one pass shortly after a listing (coalesces bursts of calls).</summary>
|
||||||
|
public void ScheduleSoon()
|
||||||
|
{
|
||||||
|
if (Interlocked.CompareExchange(ref _scheduled, 1, 0) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||||
|
Interlocked.Exchange(ref _scheduled, 0);
|
||||||
|
await UpdateAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _scheduled, 0);
|
||||||
|
_logger.LogWarning(ex, "Sort-name update failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the forced sort name on every Floatplane video item that lacks the right one.</summary>
|
||||||
|
public async Task<int> UpdateAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channelIds = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Channel],
|
||||||
|
Name = "Floatplane"
|
||||||
|
}).Select(c => c.Id).ToArray();
|
||||||
|
if (channelIds.Length == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
ChannelIds = channelIds,
|
||||||
|
IncludeItemTypes = [BaseItemKind.Video],
|
||||||
|
Recursive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var changed = new List<BaseItem>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (FloatplaneChannel.PostIdOf(item.ExternalId) is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wanted = SortNameFor(item.PremiereDate ?? item.DateCreated);
|
||||||
|
if (!string.Equals(item.ForcedSortName, wanted, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
item.ForcedSortName = wanted;
|
||||||
|
changed.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed.Count > 0)
|
||||||
|
{
|
||||||
|
await _libraryManager.UpdateItemsAsync(changed, changed[0].GetParent(), ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Set release-order sort names on {Count} Floatplane videos", changed.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed.Count;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A post is listed twice — under "All videos" (<c>post:…</c>) and under its sub-channel
|
||||||
|
/// (<c>cpost:…</c>) — because Jellyfin gives a channel item exactly one parent. Jellyfin
|
||||||
|
/// therefore keeps two sets of user data; this service copies played / resume state from
|
||||||
|
/// one copy to the other whenever it changes (pattern borrowed from chaosflix-jellyfin).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserDataMirror : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IUserDataManager _userDataManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly ILogger<UserDataMirror> _logger;
|
||||||
|
|
||||||
|
// UserDataSaved is raised from inside SaveUserData, so our own mirrored save comes
|
||||||
|
// back as an event; the copies being written are tracked here while it runs.
|
||||||
|
private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new();
|
||||||
|
|
||||||
|
public UserDataMirror(IUserDataManager userDataManager, ILibraryManager libraryManager, IUserManager userManager, ILogger<UserDataMirror> logger)
|
||||||
|
{
|
||||||
|
_userDataManager = userDataManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_userDataManager.UserDataSaved += OnUserDataSaved;
|
||||||
|
_logger.LogDebug("Floatplane user-data mirror active");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Mirror(e);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not mirror watch state of {Item}", e.Item?.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Mirror(UserDataSaveEventArgs e)
|
||||||
|
{
|
||||||
|
var item = e.Item;
|
||||||
|
var source = e.UserData;
|
||||||
|
if (item is null || source is null || !IsWatchState(e.SaveReason))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var postId = FloatplaneChannel.PostIdOf(item.ExternalId);
|
||||||
|
if (postId is null || _mirroring.ContainsKey((e.UserId, item.Id)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = _userManager.GetUserById(e.UserId);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sibling in Siblings(item, postId))
|
||||||
|
{
|
||||||
|
var target = _userDataManager.GetUserData(user, sibling);
|
||||||
|
if (target is null || !Differs(source, target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.Played = source.Played;
|
||||||
|
target.PlaybackPositionTicks = source.PlaybackPositionTicks;
|
||||||
|
target.PlayCount = source.PlayCount;
|
||||||
|
target.LastPlayedDate = source.LastPlayedDate;
|
||||||
|
|
||||||
|
if (!_mirroring.TryAdd((e.UserId, sibling.Id), 0))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_userDataManager.SaveUserData(user, sibling, target, UserDataSaveReason.UpdateUserData, CancellationToken.None);
|
||||||
|
_logger.LogDebug("Mirrored watch state of {Post} to its other copy", postId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_mirroring.TryRemove((e.UserId, sibling.Id), out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWatchState(UserDataSaveReason reason) => reason is
|
||||||
|
UserDataSaveReason.PlaybackStart or
|
||||||
|
UserDataSaveReason.PlaybackProgress or
|
||||||
|
UserDataSaveReason.PlaybackFinished or
|
||||||
|
UserDataSaveReason.TogglePlayed or
|
||||||
|
UserDataSaveReason.UpdateUserData;
|
||||||
|
|
||||||
|
private static bool Differs(UserItemData source, UserItemData target) =>
|
||||||
|
source.Played != target.Played ||
|
||||||
|
source.PlaybackPositionTicks != target.PlaybackPositionTicks ||
|
||||||
|
source.PlayCount != target.PlayCount ||
|
||||||
|
source.LastPlayedDate != target.LastPlayedDate;
|
||||||
|
|
||||||
|
private IEnumerable<BaseItem> Siblings(BaseItem item, string postId)
|
||||||
|
{
|
||||||
|
var query = new InternalItemsQuery
|
||||||
|
{
|
||||||
|
Name = item.Name,
|
||||||
|
ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty<Guid>() : new[] { item.ChannelId }
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<BaseItem> candidates;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
candidates = _libraryManager.GetItemList(query);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not look up the other copy of {Item}", item.Name);
|
||||||
|
return Array.Empty<BaseItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Where(c => !c.Id.Equals(item.Id) && string.Equals(FloatplaneChannel.PostIdOf(c.ExternalId), postId, StringComparison.Ordinal))
|
||||||
|
// Through the library cache so the instance the server serves gets the new data too.
|
||||||
|
.Select(c => _libraryManager.GetItemById(c.Id) ?? c)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,9 +32,27 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>Highest level offered as a version at all (keeps 4K out of the list by default).</summary>
|
/// <summary>Highest level offered as a version at all (keeps 4K out of the list by default).</summary>
|
||||||
public int MaxHeight { get; set; } = 1080;
|
public int MaxHeight { get; set; } = 1080;
|
||||||
|
|
||||||
/// <summary>How many posts (newest first) are listed per Floatplane channel.</summary>
|
/// <summary>How many posts (newest first) are listed per Floatplane sub-channel.</summary>
|
||||||
public int MaxPostsPerChannel { get; set; } = 100;
|
public int MaxPostsPerChannel { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>How many posts (newest first) the per-creator "All videos" folder lists.</summary>
|
||||||
|
public int MaxPostsAllVideos { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth client id registered with Floatplane for this plugin. Empty = OAuth login
|
||||||
|
/// unavailable (use the cookie). See the README for how to obtain one.
|
||||||
|
/// </summary>
|
||||||
|
public string OAuthClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>OAuth access token (short-lived, refreshed automatically).</summary>
|
||||||
|
public string OAuthAccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>OAuth refresh token (≈90 days, rotated on refresh). Presence = logged in via OAuth.</summary>
|
||||||
|
public string OAuthRefreshToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Unix time (seconds) when the access token expires.</summary>
|
||||||
|
public long OAuthAccessTokenExpiry { get; set; }
|
||||||
|
|
||||||
/// <summary>Show posts the account cannot access (higher tier) as locked items.</summary>
|
/// <summary>Show posts the account cannot access (higher tier) as locked items.</summary>
|
||||||
public bool ShowLockedPosts { get; set; }
|
public bool ShowLockedPosts { get; set; }
|
||||||
|
|
||||||
@@ -59,6 +77,9 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>Hours between automatic catalogue refreshes (scheduled task).</summary>
|
/// <summary>Hours between automatic catalogue refreshes (scheduled task).</summary>
|
||||||
public int SyncIntervalHours { get; set; } = 6;
|
public int SyncIntervalHours { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>Version of the channel logo Jellyfin last stored (internal; drives a one-time image refresh).</summary>
|
||||||
|
public int ChannelImageVersion { get; set; }
|
||||||
|
|
||||||
/// <summary>Report watch progress back to Floatplane (not implemented yet).</summary>
|
/// <summary>Report watch progress back to Floatplane (not implemented yet).</summary>
|
||||||
public bool SyncProgressToFloatplane { get; set; }
|
public bool SyncProgressToFloatplane { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,23 @@
|
|||||||
<button is="emby-button" type="button" id="btnValidate" class="raised"><span>Validate cookie</span></button>
|
<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>
|
<span id="validateResult" class="fieldDescription" style="margin-left:1em"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="sectionTitle" style="margin-top:1.5em">Login with Floatplane (OAuth, optional)</h3>
|
||||||
|
<div class="fieldDescription" style="margin-bottom:0.5em">
|
||||||
|
Floatplane's own apps and third-party clients log in through an OAuth device flow instead of a cookie.
|
||||||
|
That needs a <b>client id</b> issued by Floatplane for this plugin — there is no self-service registration.
|
||||||
|
If you have one, enter it and press "Login with Floatplane"; the OAuth session then replaces the cookie
|
||||||
|
(refresh tokens last about 90 days and renew themselves).
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel" for="OAuthClientId">OAuth client id</label>
|
||||||
|
<input is="emby-input" type="text" id="OAuthClientId" placeholder="(none)" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="button" id="btnOAuth" class="raised"><span>Login with Floatplane</span></button>
|
||||||
|
<button is="emby-button" type="button" id="btnOAuthLogout" class="raised" hidden><span>Forget OAuth session</span></button>
|
||||||
|
<div id="oauthResult" class="fieldDescription" style="margin-top:0.5em"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="verticalSection">
|
<div class="verticalSection">
|
||||||
@@ -40,9 +57,14 @@
|
|||||||
<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 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>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel" for="MaxPostsPerChannel">Videos listed per channel</label>
|
<label class="inputLabel" for="MaxPostsAllVideos">Videos listed in "All videos"</label>
|
||||||
|
<input is="emby-input" type="number" id="MaxPostsAllVideos" min="20" max="5000" step="20" />
|
||||||
|
<div class="fieldDescription">Each creator gets an "All videos" folder with every channel's videos in release order, newest first. Floatplane pages 20 at a time, so 200 = 10 requests.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel" for="MaxPostsPerChannel">Videos listed per sub-channel</label>
|
||||||
<input is="emby-input" type="number" id="MaxPostsPerChannel" min="20" max="2000" step="20" />
|
<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 class="fieldDescription">Newest first, per sub-channel folder (TechLinked, ShortCircuit, …).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
@@ -126,8 +148,8 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function () {
|
(function () {
|
||||||
var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
|
var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
|
||||||
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "CatalogCacheMinutes", "SyncIntervalHours",
|
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "MaxPostsAllVideos", "CatalogCacheMinutes", "SyncIntervalHours",
|
||||||
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl"];
|
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl", "OAuthClientId"];
|
||||||
var checks = ["ShowLockedPosts", "EnableMp4Sources"];
|
var checks = ["ShowLockedPosts", "EnableMp4Sources"];
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
function el(id) { return document.getElementById(id); }
|
||||||
@@ -138,15 +160,17 @@
|
|||||||
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) {
|
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) {
|
||||||
var lines = [];
|
var lines = [];
|
||||||
lines.push("Plugin " + esc(s.Version));
|
lines.push("Plugin " + esc(s.Version));
|
||||||
|
var kind = s.SessionKind === "oauth" ? "OAuth" : "cookie";
|
||||||
if (!s.HasSessionCookie) {
|
if (!s.HasSessionCookie) {
|
||||||
lines.push("Session: <b>no cookie</b> — browsing only.");
|
lines.push("Session: <b>none</b> — browsing only (public listings); paste a cookie or log in to play.");
|
||||||
} else if (s.SessionState === "ok") {
|
} else if (s.SessionState === "ok") {
|
||||||
lines.push("Session: <b>ok</b>" + (s.SessionUsername ? " (" + esc(s.SessionUsername) + ")" : ""));
|
lines.push("Session: <b>ok</b> via " + kind + (s.SessionUsername ? " (" + esc(s.SessionUsername) + ")" : ""));
|
||||||
} else if (s.SessionState === "invalid") {
|
} else if (s.SessionState === "invalid") {
|
||||||
lines.push("Session: <b style='color:#e55'>rejected</b> — paste a fresh cookie. " + esc(s.LastError || ""));
|
lines.push("Session (" + kind + "): <b style='color:#e55'>rejected</b> — " + (s.SessionKind === "oauth" ? "log in again." : "paste a fresh cookie. ") + esc(s.LastError || ""));
|
||||||
} else {
|
} else {
|
||||||
lines.push("Session: cookie set, not checked yet.");
|
lines.push("Session: " + kind + " configured, not checked yet.");
|
||||||
}
|
}
|
||||||
|
el("btnOAuthLogout").hidden = s.SessionKind !== "oauth";
|
||||||
lines.push("Creators: " + s.Creators.length + (s.LastRefresh ? " (refreshed " + new Date(s.LastRefresh).toLocaleString() + ")" : " (not loaded 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(", "))); });
|
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);
|
lines.push("Cached post lists: " + s.CachedPostLists + ", cached deliveries: " + s.CachedDeliveries + ", generation " + s.Generation);
|
||||||
@@ -213,6 +237,44 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var oauthTimer = null;
|
||||||
|
function oauthStart() {
|
||||||
|
var out = el("oauthResult");
|
||||||
|
var clientId = el("OAuthClientId").value.trim();
|
||||||
|
if (!clientId) { out.textContent = "Enter the client id Floatplane issued for this plugin first."; return; }
|
||||||
|
out.textContent = "Contacting auth.floatplane.com…";
|
||||||
|
ApiClient.ajax({
|
||||||
|
type: "POST", url: ApiClient.getUrl("Floatplane/OAuth/Start"),
|
||||||
|
data: JSON.stringify({ ClientId: clientId }), contentType: "application/json", dataType: "json"
|
||||||
|
}).then(function (r) {
|
||||||
|
var link = r.VerificationUriComplete || r.VerificationUri;
|
||||||
|
out.innerHTML = "Open <a href='" + esc(link) + "' target='_blank' rel='noopener'>" + esc(link) + "</a> and enter code <b style='font-size:1.3em'>" + esc(r.UserCode) + "</b> (valid " + Math.round(r.ExpiresIn / 60) + " min). Waiting…";
|
||||||
|
var interval = (r.Interval || 5) * 1000;
|
||||||
|
clearInterval(oauthTimer);
|
||||||
|
oauthTimer = setInterval(function () {
|
||||||
|
ApiClient.getJSON(ApiClient.getUrl("Floatplane/OAuth/Poll")).then(function (s) {
|
||||||
|
if (s.State === "pending") { return; }
|
||||||
|
clearInterval(oauthTimer);
|
||||||
|
out.textContent = s.Message || s.State;
|
||||||
|
out.style.color = s.State === "done" ? "#4c4" : "#e55";
|
||||||
|
if (s.State === "done") { loadStatus(); }
|
||||||
|
}).catch(function () { clearInterval(oauthTimer); out.textContent = "Polling failed."; });
|
||||||
|
}, interval);
|
||||||
|
}).catch(function (err) {
|
||||||
|
var msg = "Start failed";
|
||||||
|
try { msg += ": " + (err.responseText || ("HTTP " + err.status)); } catch (e) { /* ignore */ }
|
||||||
|
out.textContent = msg + " — usually the client id is not registered with Floatplane (invalid_client).";
|
||||||
|
out.style.color = "#e55";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthLogout() {
|
||||||
|
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/OAuth/Logout") }).then(function () {
|
||||||
|
el("oauthResult").textContent = "OAuth session forgotten.";
|
||||||
|
loadStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
el("fpStatus").textContent = "Refreshing…";
|
el("fpStatus").textContent = "Refreshing…";
|
||||||
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
||||||
@@ -238,6 +300,8 @@
|
|||||||
el("FloatplaneConfigForm").addEventListener("submit", saveConfig);
|
el("FloatplaneConfigForm").addEventListener("submit", saveConfig);
|
||||||
el("btnValidate").addEventListener("click", validate);
|
el("btnValidate").addEventListener("click", validate);
|
||||||
el("btnRefresh").addEventListener("click", refresh);
|
el("btnRefresh").addEventListener("click", refresh);
|
||||||
|
el("btnOAuth").addEventListener("click", oauthStart);
|
||||||
|
el("btnOAuthLogout").addEventListener("click", oauthLogout);
|
||||||
el("btnSearch").addEventListener("click", search);
|
el("btnSearch").addEventListener("click", search);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
<None Remove="Resources\logo.png" />
|
<None Remove="Resources\logo.png" />
|
||||||
<EmbeddedResource Include="Resources\logo.png" />
|
<EmbeddedResource Include="Resources\logo.png" />
|
||||||
|
<None Remove="Resources\thumb.png" />
|
||||||
|
<EmbeddedResource Include="Resources\thumb.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||||
});
|
});
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton<FloatplaneAuthService>();
|
||||||
serviceCollection.AddSingleton<FloatplaneApiClient>();
|
serviceCollection.AddSingleton<FloatplaneApiClient>();
|
||||||
serviceCollection.AddSingleton<CatalogService>();
|
serviceCollection.AddSingleton<CatalogService>();
|
||||||
serviceCollection.AddSingleton<DeliveryResolver>();
|
serviceCollection.AddSingleton<DeliveryResolver>();
|
||||||
|
|
||||||
// One instance: the status endpoint and the sync task must see the same caches
|
// One instance: the status endpoint and the sync task must see the same caches
|
||||||
// as the channel Jellyfin serves from.
|
// as the channel Jellyfin serves from.
|
||||||
|
serviceCollection.AddSingleton<SortNameUpdater>();
|
||||||
serviceCollection.AddSingleton<FloatplaneChannel>();
|
serviceCollection.AddSingleton<FloatplaneChannel>();
|
||||||
serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>());
|
serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>());
|
||||||
|
|
||||||
|
// The same post appears under "All videos" and under its sub-channel (Jellyfin
|
||||||
|
// allows one parent per item id); this keeps watched/resume state in sync.
|
||||||
|
serviceCollection.AddHostedService<UserDataMirror>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 49 KiB |
BIN
Jellyfin.Plugin.Floatplane/Resources/thumb.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -2,7 +2,14 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Plugin.Floatplane.Channel;
|
using Jellyfin.Plugin.Floatplane.Channel;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -14,12 +21,23 @@ namespace Jellyfin.Plugin.Floatplane.Tasks;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
|
public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
|
||||||
{
|
{
|
||||||
|
/// <summary>Bump when Resources/logo.png changes so existing installs get the new channel image.</summary>
|
||||||
|
public const int ChannelImageVersion = 3;
|
||||||
|
|
||||||
private readonly CatalogService _catalog;
|
private readonly CatalogService _catalog;
|
||||||
|
private readonly SortNameUpdater _sortNames;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly ILogger<FloatplaneSyncTask> _logger;
|
private readonly ILogger<FloatplaneSyncTask> _logger;
|
||||||
|
|
||||||
public FloatplaneSyncTask(CatalogService catalog, ILogger<FloatplaneSyncTask> logger)
|
public FloatplaneSyncTask(CatalogService catalog, SortNameUpdater sortNames, ILibraryManager libraryManager, IProviderManager providerManager, IFileSystem fileSystem, ILogger<FloatplaneSyncTask> logger)
|
||||||
{
|
{
|
||||||
_catalog = catalog;
|
_catalog = catalog;
|
||||||
|
_sortNames = sortNames;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_providerManager = providerManager;
|
||||||
|
_fileSystem = fileSystem;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +68,58 @@ public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
_logger.LogInformation("Floatplane sync started");
|
_logger.LogInformation("Floatplane sync started");
|
||||||
await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false);
|
await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||||
_logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation);
|
_logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation);
|
||||||
|
await UpdateChannelImageIfNeededAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await _sortNames.UpdateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jellyfin stores the channel's image once, so when the plugin ships a new logo the
|
||||||
|
/// stored Primary is replaced explicitly (a metadata refresh does not replace it), and
|
||||||
|
/// Thumb/Backdrop images from earlier versions are removed because video cards inherit
|
||||||
|
/// the parent thumb in "Continue watching". Runs once per <see cref="ChannelImageVersion"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task UpdateChannelImageIfNeededAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = Plugin.Instance?.Configuration;
|
||||||
|
if (cfg == null || cfg.ChannelImageVersion >= ChannelImageVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channels = _libraryManager.GetItemList(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
IncludeItemTypes = [BaseItemKind.Channel],
|
||||||
|
Name = "Floatplane"
|
||||||
|
});
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
foreach (var type in new[] { ImageType.Thumb, ImageType.Backdrop, ImageType.Logo })
|
||||||
|
{
|
||||||
|
while (channel.HasImage(type))
|
||||||
|
{
|
||||||
|
await channel.DeleteImageAsync(type, 0).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Floatplane.Resources.logo.png");
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
await _providerManager.SaveImage(channel, stream, "image/png", ImageType.Primary, null, ct).ConfigureAwait(false);
|
||||||
|
await _libraryManager.UpdateItemAsync(channel, channel.GetParent(), ItemUpdateType.ImageUpdate, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Updated channel image for {Name} (logo v{Version})", channel.Name, ChannelImageVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.ChannelImageVersion = ChannelImageVersion;
|
||||||
|
Plugin.Instance!.SaveConfiguration();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not update the channel image");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
190
README.md
@@ -1,116 +1,150 @@
|
|||||||
# jellyfin-plugin-floatplane
|
# Floatplane for Jellyfin
|
||||||
|
|
||||||
A Jellyfin server plugin that exposes your [Floatplane](https://www.floatplane.com)
|
A [Jellyfin](https://jellyfin.org) server plugin that brings your
|
||||||
subscriptions (creators → channels → videos, plus live streams) inside
|
[Floatplane](https://www.floatplane.com) subscriptions into Jellyfin.
|
||||||
Jellyfin and all its clients, so Floatplane content shows up next to the
|
Creators, their sub-channels and every video appear as a **Floatplane**
|
||||||
rest of the library on the Pi5 server and plays through the normal
|
channel under *My Media* in the web client and in all Jellyfin apps, and
|
||||||
Jellyfin players.
|
play through the normal Jellyfin player — with watched state, resume,
|
||||||
|
"Continue Watching", "Recently Added" and search, like any other library.
|
||||||
|
|
||||||
**Status (2026-10-25):** browsing works (verified against a local Jellyfin
|

|
||||||
10.10.7); playback proxy is implemented but still needs a real session
|
|
||||||
cookie to be verified. See [`doc/plan.md`](doc/plan.md) for milestones and
|
|
||||||
[`doc/floatplane-api-notes.md`](doc/floatplane-api-notes.md) for the API.
|
|
||||||
|
|
||||||
The plugin is a pure managed .NET assembly (AnyCPU IL), so one build runs
|
## Features
|
||||||
on both `linux-x64` (dev box) and `linux-arm64` (the Pi5).
|
|
||||||
|
|
||||||
## Prerequisites
|
- **Creators → All videos → sub-channels.** Each creator you subscribe to
|
||||||
|
gets an *All videos* folder (every sub-channel in release order, newest
|
||||||
|
first) plus one folder per sub-channel (TechLinked, ShortCircuit, …).
|
||||||
|
- **Real metadata**: thumbnail, description, duration, release date,
|
||||||
|
sub-channel as *Genre*, creator as *Studio*.
|
||||||
|
- **Newest first by default** in every Jellyfin client, without touching
|
||||||
|
the sort menu.
|
||||||
|
- **Watched/resume state is shared** between the *All videos* copy and the
|
||||||
|
sub-channel copy of a video.
|
||||||
|
- **Low-CPU playback**: the server remuxes Floatplane's H.264/AAC stream
|
||||||
|
(stream copy, no re-encode). Quality levels show up as *Versions*.
|
||||||
|
- **Home screen integration**: "Recently Added in Floatplane" and
|
||||||
|
"Continue Watching" work out of the box.
|
||||||
|
- **Browse without logging in**: public creators (e.g. `linustechtips`)
|
||||||
|
can be listed by url-name; playback needs a logged-in session.
|
||||||
|
- **Search test tool** for Floatplane's own search API on the settings
|
||||||
|
page, and a scheduled task that refreshes the catalogue.
|
||||||
|
|
||||||
| Target | What to install |
|

|
||||||
|--------|-----------------|
|
|
||||||
| Arch / Garuda (dev box) | `sudo pacman -S dotnet-sdk-8.0` (Jellyfin 10.10.x = .NET 8) — for Jellyfin 12.x use `dotnet-sdk` (.NET 10) |
|
|
||||||
| Debian / Ubuntu | `apt install dotnet-sdk-8.0` from the Microsoft repo |
|
|
||||||
| Pi5 (runtime only) | nothing — the plugin runs inside the existing `linuxserver/jellyfin` container |
|
|
||||||
|
|
||||||
Optional, for local testing: Docker (to run a throw-away Jellyfin of the
|
## Requirements
|
||||||
same version as the Pi5 with the plugin folder mounted).
|
|
||||||
|
|
||||||
## Build
|
| | |
|
||||||
|
|---|---|
|
||||||
```bash
|
| Jellyfin | **10.10.x** (plugin targetAbi `10.10.0.0`, .NET 8). Jellyfin 12.x needs a separate build (not available yet). |
|
||||||
git clone https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane.git
|
| Architecture | one AnyCPU DLL — runs unchanged on x64 and arm64 (Raspberry Pi 4/5 etc.). |
|
||||||
cd jellyfin-plugin-floatplane
|
| Floatplane | an account with an active subscription for playback. |
|
||||||
scripts/package.sh # → build/Floatplane_<ver>/ (DLL + meta.json), build/floatplane_<ver>.zip (+ .md5)
|
|
||||||
```
|
|
||||||
|
|
||||||
The VS Code `build` task (`Ctrl+Shift+B`) runs the same script. Plain
|
|
||||||
`dotnet build` works too (`jellyfin-plugin-floatplane.sln`).
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
**From the plugin repository (recommended):** Dashboard → Plugins →
|
**From the plugin repository (recommended)**
|
||||||
Repositories → add
|
|
||||||
|
1. Dashboard → Plugins → **Repositories** → **+**. Name it anything and use
|
||||||
|
this URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
|
https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
then Catalog → Channels → Floatplane → Install → restart Jellyfin.
|
2. Dashboard → Plugins → **Catalog** → section *Channels* → **Floatplane**
|
||||||
|
→ **Install**.
|
||||||
|
3. Restart Jellyfin. The plugin shows as *Active* under *My Plugins*.
|
||||||
|
|
||||||
**Manual, on the Pi5 (linuxserver image):**
|
Updates then appear in the catalog like any other plugin.
|
||||||
|
|
||||||
|
**With the install script** (Docker — linuxserver.io or official image — or
|
||||||
|
a native install; detects the right plugin directory, verifies the checksum,
|
||||||
|
restarts Jellyfin):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install-pi5.sh | sh -s -- --dry-run
|
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh -s -- --dry-run
|
||||||
# then without --dry-run; it installs into /srv/docker/jellyfin/data/plugins/ and restarts the container
|
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual, from a local build:**
|
Environment overrides: `TAG` (`latest` or `vX.Y.Z`), `JELLYFIN_CONTAINER`
|
||||||
|
(default `jellyfin`, empty = native), `JELLYFIN_CONFIG_DIR`, `PLUGINS_DIR`,
|
||||||
|
`PUID`/`PGID`.
|
||||||
|
|
||||||
```bash
|
**Manually**: download `floatplane_<version>.zip` from the
|
||||||
# linuxserver image keeps data under /config/data (container path);
|
[releases](https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases),
|
||||||
# host path on the Pi5: /srv/docker/jellyfin/data/plugins/ (infra-Doc services/media-stack.md)
|
unzip it into `<jellyfin data>/plugins/Floatplane_<version>/` and restart.
|
||||||
cp -r build/Floatplane_0.1.0.0 /srv/docker/jellyfin/data/plugins/
|
|
||||||
docker restart jellyfin
|
|
||||||
```
|
|
||||||
|
|
||||||
> The Pi5 runs Jellyfin **10.10.7** but `lscr.io/linuxserver/jellyfin:latest`
|
## Log in
|
||||||
> is already **12.3** (.NET 10). Pin the tag (`10.10.7ubu2404-ls80`) before
|
|
||||||
> any `docker pull`, or the plugin becomes `NotSupported` overnight.
|
|
||||||
|
|
||||||
Then Dashboard → Plugins → Floatplane → paste your `sails.sid` cookie
|
Floatplane's password login requires a Cloudflare captcha that only works on
|
||||||
(the page explains where to find it) → Validate → Save. A "Floatplane"
|
floatplane.com, so the plugin cannot ask for your username and password.
|
||||||
channel appears under My Media: creators → sub-channels → videos.
|
Two options, both on **Dashboard → Plugins → Floatplane**:
|
||||||
Without a cookie you can still browse creators listed under
|
|
||||||
"Extra creators" (public data); playback needs the cookie.
|
1. **Session cookie** (works today): log in at floatplane.com in a browser,
|
||||||
|
copy the value of the `sails.sid` cookie (DevTools → Application/Storage
|
||||||
|
→ Cookies), paste it into the settings page, press **Validate cookie**
|
||||||
|
(shows your username and subscriptions) and **Save**.
|
||||||
|
2. **Login with Floatplane** (OAuth device flow — the way Floatplane's own
|
||||||
|
apps and clients like Hydravion or Wasserflug log in). This needs a
|
||||||
|
client id issued by Floatplane; without one the button reports
|
||||||
|
`invalid_client`.
|
||||||
|
|
||||||
|
Step-by-step instructions, every setting explained and troubleshooting:
|
||||||
|
**[doc/usage.md](doc/usage.md)**.
|
||||||
|
|
||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
- `FloatplaneChannel` (`IChannel`) lists creators → sub-channels → posts
|
- The channel lists creators → *All videos* / sub-channels → posts from
|
||||||
from Floatplane's public listing API; ids are `creator:…`, `channel:…`,
|
Floatplane's public listing API (ids `creator:…`, `all:…`, `channel:…`,
|
||||||
`post:…`.
|
`post:…`, `cpost:…`).
|
||||||
- On play, `IRequiresMediaInfoCallback` fetches the post (login) and hands
|
- On play, the plugin fetches the post details with your session and hands
|
||||||
Jellyfin signed URLs to the plugin's own `Floatplane/Stream/…` endpoints.
|
Jellyfin signed URLs to its own `Floatplane/Stream/…` endpoints.
|
||||||
- The stream endpoint calls `delivery/info` (cached, throttled to ~2/min),
|
- The stream endpoint asks Floatplane for the stream info (cached 6 h,
|
||||||
fetches the HLS playlist, and rewrites it: the AES-128 key URI points
|
throttled to the ~2/min the API allows), downloads the HLS playlist and
|
||||||
back at the plugin (which fetches the key with the cookie), segments go
|
rewrites it: the AES-128 key is fetched through the plugin with your
|
||||||
straight to the CDN. ffmpeg then remuxes (stream copy) for the client.
|
session, the video segments go straight to Floatplane's CDN. Jellyfin's
|
||||||
- A scheduled task refreshes the catalogue and bumps the channel cache key.
|
ffmpeg then stream-copies the result to the client.
|
||||||
|
- A scheduled task refreshes the catalogue and bumps the channel cache key
|
||||||
|
so Jellyfin re-lists the folders.
|
||||||
|
|
||||||
## Test
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet test # unit tests (models, HLS rewriter, API client with fixtures from the HAR)
|
git clone https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane.git
|
||||||
|
cd jellyfin-plugin-floatplane
|
||||||
|
dotnet test # 59 unit tests (models, HLS rewriter, API client, catalogue)
|
||||||
|
scripts/package.sh # → build/Floatplane_<ver>/ (DLL, meta.json, thumb.png), build/floatplane_<ver>.zip (+ .md5)
|
||||||
|
scripts/package.sh 1.2.3.0 https://example.com/dl # versioned package + build/manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The test host needs the ASP.NET Core 8 shared runtime (the plugin references
|
- Needs the .NET 8 SDK; the test host also needs the ASP.NET Core 8 shared
|
||||||
Jellyfin's controllers): on Arch `sudo pacman -S aspnet-runtime-8.0`.
|
runtime (Arch: `sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0`).
|
||||||
|
- The VS Code `build` task (`Ctrl+Shift+B`) runs `scripts/package.sh`.
|
||||||
Local end-to-end test with a disposable Jellyfin matching the Pi5 version:
|
- Local end-to-end test against a throw-away Jellyfin of the right version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .jellyfin-test/config/data/plugins
|
mkdir -p .jellyfin-test/config/data/plugins
|
||||||
cp -r build .jellyfin-test/config/data/plugins/Floatplane_0.1.0.0 # writable copy, not :ro
|
cp -r build/Floatplane_0.2.0.0 .jellyfin-test/config/data/plugins/ # writable copy
|
||||||
docker run --rm -p 8096:8096 -v $PWD/.jellyfin-test/config:/config \
|
docker run --rm -p 8096:8096 -v "$PWD/.jellyfin-test/config:/config" \
|
||||||
lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80
|
lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80
|
||||||
# logs: .jellyfin-test/config/log/log_*.log ; plugin status: Dashboard → Plugins
|
# logs: .jellyfin-test/config/log/log_*.log — plugin status: Dashboard → Plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docs
|
- Every push to `master` runs the tests and publishes the rolling `latest`
|
||||||
|
release (zip, md5, `manifest.json`, `install.sh`); a `vX.Y.Z` tag publishes
|
||||||
|
a versioned release.
|
||||||
|
|
||||||
- `doc/plan.md` — goal, stack, milestones, roadmap
|
More: [`doc/plan.md`](doc/plan.md) (milestones, roadmap),
|
||||||
- `doc/floatplane-api-notes.md` — endpoints, auth, HLS/AES-128 playback flow
|
[`doc/floatplane-api-notes.md`](doc/floatplane-api-notes.md) (the API as
|
||||||
- `doc/research/jellyfin-plugin-research.md` — plugin API research (10.10 vs 12.x, channels, proxy pattern, packaging, paths)
|
observed), [`doc/research/`](doc/research/) (Jellyfin plugin + Floatplane
|
||||||
- `doc/research/floatplane-api-research.md` — community API docs, OAuth device flow, DPoP, rate limits, other clients
|
API research and reference sources), and the
|
||||||
- `doc/research/reference-sources/` — snapshots of other clients' source for study (chaosflix is the template to follow)
|
[project wiki](https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/wiki).
|
||||||
- `doc/har/` — the captured browser session (**gitignored, contains credentials**)
|
|
||||||
- Gitea wiki (once the repo exists) — architecture and design decisions
|
## Credits & disclaimer
|
||||||
|
|
||||||
|
Unofficial, not affiliated with or endorsed by Floatplane Media Inc.
|
||||||
|
The plugin uses Floatplane's unofficial API documented by the community
|
||||||
|
([jamamp/FloatplaneAPI](https://github.com/jamamp/FloatplaneAPI)) and
|
||||||
|
borrows the channel + signed-proxy pattern from
|
||||||
|
[chaosflix-jellyfin](https://github.com/praetorianer777/chaosflix-jellyfin).
|
||||||
|
Use it with your own subscription, for personal viewing. The Floatplane
|
||||||
|
name and logo belong to Floatplane Media Inc.
|
||||||
|
|||||||
@@ -15,4 +15,6 @@ owner: "brasse"
|
|||||||
artifacts:
|
artifacts:
|
||||||
- "Jellyfin.Plugin.Floatplane.dll"
|
- "Jellyfin.Plugin.Floatplane.dll"
|
||||||
changelog: >
|
changelog: >
|
||||||
Initial release: channel browsing, HLS playback proxy, cookie session, search test.
|
"All videos" folder per creator (release order across sub-channels) with shared
|
||||||
|
watched state, official Floatplane icon, OAuth device-flow login (needs a client id),
|
||||||
|
generic install script, documentation with screenshots.
|
||||||
|
|||||||
BIN
doc/img/all-videos.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
doc/img/channel-root.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
doc/img/creator-folder.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
doc/img/dashboard-plugins.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
doc/img/home.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
doc/img/plugin-catalog-floatplane.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
doc/img/plugin-catalog.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
doc/img/plugin-repositories.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
doc/img/scheduled-tasks.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
doc/img/settings.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
doc/img/video-details.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
12
doc/plan.md
@@ -103,11 +103,13 @@ Jellyfin clients ──► Jellyfin server (Pi5)
|
|||||||
| # | Increment | Done when |
|
| # | Increment | Done when |
|
||||||
|---|-----------|-----------|
|
|---|-----------|-----------|
|
||||||
| M0 | Scaffold: csproj (net8/10.10.7), Plugin.cs, config page, `meta.json`, build task, unit-test project, local docker Jellyfin 10.10.7 | **done 2026-10-25** — Active in a local 10.10.7 (LSIO image) |
|
| M0 | Scaffold: csproj (net8/10.10.7), Plugin.cs, config page, `meta.json`, build task, unit-test project, local docker Jellyfin 10.10.7 | **done 2026-10-25** — Active in a local 10.10.7 (LSIO image) |
|
||||||
| M1 | `FloatplaneApiClient`: cookie session, `user/self`, subscriptions, creator/info, content listing with pagination + search/filters, post details; fixtures from the HAR | **code done** — config page validates the cookie; unit tests in `Jellyfin.Plugin.Floatplane.Tests` |
|
| M1 | `FloatplaneApiClient`: cookie session, `user/self`, subscriptions, creator/info, content listing with pagination + search/filters, post details; fixtures from the HAR | **done** — config page validates the cookie; 59 unit tests in `Jellyfin.Plugin.Floatplane.Tests` |
|
||||||
| M2 | Channel browse: root → creators → sub-channels → videos with thumbnails, overview, duration, release date; sync task + cache key bump | **done locally** (web API verified: folders, paging, sorting, latest, images); Android app not yet checked |
|
| M2 | Channel browse: root → creators → *All videos* + sub-channels → videos with thumbnails, overview, duration, release date; sync task + cache key bump | **done 2026-10-25** — verified in the web client (folders, paging, newest-first default sort, latest, images); *All videos* per creator added, watched state mirrored between the two copies of a video; official Floatplane logo as channel image and plugin thumbnail; Android app not yet checked |
|
||||||
| M3 | Playback via proxy controller: delivery/info cache + throttle, HLS playlist rewrite with key proxy (+ optional MP4 relay), `MediaStreams` filled so Jellyfin remuxes instead of transcoding | **code done, untested** — needs a real session cookie (Bjorn, on the Pi5 or locally) |
|
| M3 | Playback via proxy controller: delivery/info cache + throttle, HLS playlist rewrite with key proxy (+ optional MP4 relay), `MediaStreams` filled so Jellyfin remuxes instead of transcoding | **code done, still unverified** — needs a real session cookie on a running server |
|
||||||
| M4 | Packaging + CI: Gitea Actions build → zip + md5 + `manifest.json` → releases; install on Pi5 from the repository URL or `install-pi5.sh` | **code done** — first CI run pending the initial push |
|
| M4 | Packaging + CI: Gitea Actions build → zip + md5 + `manifest.json` + `thumb.png` → releases; install from the repository URL or `install.sh` (generic: LSIO / official image / native) | **done** — CI green, install from the manifest verified locally |
|
||||||
| M5 | Polish: watched/resume sync to Floatplane progress API, subtitles (VTT text tracks), locked-tier handling, cookie-expiry warning, Android/TV app check | next |
|
| M5 | Login without cookie copying: OAuth device flow (`Login with Floatplane`) | **plumbing done 2026-10-25** — works up to `invalid_client`; waits for a client id from Floatplane. Password login inside Jellyfin is impossible (Turnstile is domain-bound, error 110200 verified) |
|
||||||
|
| M6 | Documentation for a general audience: README, `doc/usage.md` with screenshots, settings reference, troubleshooting | **done 2026-10-25** |
|
||||||
|
| M7 | Polish: watched/resume sync to Floatplane's progress API, subtitles (VTT text tracks), Android/TV app check, Jellyfin 12.x build | next |
|
||||||
|
|
||||||
## Roadmap / expansions (to agree)
|
## Roadmap / expansions (to agree)
|
||||||
|
|
||||||
|
|||||||
281
doc/usage.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Floatplane for Jellyfin — user guide
|
||||||
|
|
||||||
|
Everything below refers to the Jellyfin web client; the Android, Android
|
||||||
|
TV, iOS and desktop apps show the same channel and folders.
|
||||||
|
|
||||||
|
Contents: [Install](#install) · [Log in](#log-in) · [Browse and play](#browse-and-play) ·
|
||||||
|
[Settings reference](#settings-reference) · [Scheduled task](#scheduled-task) ·
|
||||||
|
[Troubleshooting](#troubleshooting) · [Privacy and security](#privacy-and-security)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### From the plugin repository
|
||||||
|
|
||||||
|
1. Open **Dashboard → Plugins → Repositories** and press **+**.
|
||||||
|
Give it any name and enter the URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
2. Go to **Dashboard → Plugins → Catalog**, scroll to the *Channels* section
|
||||||
|
and open **Floatplane**, then press **Install**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. Restart Jellyfin. **Dashboard → Plugins → My Plugins** now lists Floatplane
|
||||||
|
as *Active*. Click the card (or the ⋮ menu → Settings) to open the
|
||||||
|
settings page.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
New versions show up in the catalog like for any other plugin.
|
||||||
|
|
||||||
|
### With the install script
|
||||||
|
|
||||||
|
The script downloads the release, verifies the MD5 checksum, puts the plugin
|
||||||
|
in the right folder and restarts Jellyfin. It understands the
|
||||||
|
linuxserver.io image (`<config>/data/plugins`), the official image
|
||||||
|
(`<config>/plugins`) and native installs (`/var/lib/jellyfin/plugins`).
|
||||||
|
Run it as a user that may write there and restart Jellyfin (usually with
|
||||||
|
`sudo`). `--dry-run` only prints what would happen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh -s -- --dry-run
|
||||||
|
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Meaning | Default |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `TAG` | release to install | `latest` (or `vX.Y.Z`) |
|
||||||
|
| `JELLYFIN_CONTAINER` | Docker container to inspect and restart; empty = native install | `jellyfin` |
|
||||||
|
| `JELLYFIN_CONFIG_DIR` | host path of the container's `/config` (or the data dir of a native install) | read from `docker inspect` |
|
||||||
|
| `PLUGINS_DIR` | plugin directory, overrides the detection | detected |
|
||||||
|
| `PUID` / `PGID` | owner of the plugin files | from the container, else the directory owner |
|
||||||
|
|
||||||
|
### Manually
|
||||||
|
|
||||||
|
Download `floatplane_<version>.zip` from the
|
||||||
|
[releases page](https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases),
|
||||||
|
unzip it into `<jellyfin data>/plugins/Floatplane_<version>/` (the folder
|
||||||
|
must contain `Jellyfin.Plugin.Floatplane.dll` and `meta.json`) and restart
|
||||||
|
Jellyfin.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Jellyfin **10.10.x**. The plugin targets ABI `10.10.0.0` and .NET 8;
|
||||||
|
Jellyfin 12.x uses a different ABI and needs a separate build (not yet
|
||||||
|
available).
|
||||||
|
- Any CPU architecture Jellyfin runs on: the plugin is one managed DLL
|
||||||
|
(x64 and arm64 verified).
|
||||||
|
- A Floatplane account with an active subscription for playback. Browsing
|
||||||
|
public creators works without logging in.
|
||||||
|
|
||||||
|
## Log in
|
||||||
|
|
||||||
|
Floatplane's username/password login is protected by a Cloudflare Turnstile
|
||||||
|
captcha that is bound to floatplane.com (any other site gets error 110200),
|
||||||
|
so the plugin cannot log you in with your password. Instead, open
|
||||||
|
**Dashboard → Plugins → Floatplane** and use one of the two methods below.
|
||||||
|
The status block at the top of the page always tells you which session is
|
||||||
|
in use and whether Floatplane still accepts it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Option 1 — paste the session cookie (works today)
|
||||||
|
|
||||||
|
1. Log in at [floatplane.com](https://www.floatplane.com) in a browser.
|
||||||
|
2. Open the browser's developer tools and find the cookie `sails.sid` for
|
||||||
|
`https://www.floatplane.com`:
|
||||||
|
- **Chrome / Edge**: F12 → *Application* → *Storage* → *Cookies* →
|
||||||
|
`https://www.floatplane.com`.
|
||||||
|
- **Firefox**: F12 → *Storage* → *Cookies* → `https://www.floatplane.com`.
|
||||||
|
- **Safari**: enable the Develop menu, then *Develop → Show Web Inspector*
|
||||||
|
→ *Storage* → *Cookies*.
|
||||||
|
3. Copy the **Value** of `sails.sid` (a long string; `sails.sid=` in front
|
||||||
|
is fine, the plugin strips it).
|
||||||
|
4. Paste it into **sails.sid cookie** and press **Validate cookie**. The page
|
||||||
|
shows your username and active subscriptions.
|
||||||
|
5. Press **Save**. The creators you subscribe to appear in the channel after
|
||||||
|
the automatic refresh that follows.
|
||||||
|
|
||||||
|
When the cookie expires, the status line changes to *rejected*. Log in at
|
||||||
|
floatplane.com again, copy the new value and repeat.
|
||||||
|
|
||||||
|
### Option 2 — Login with Floatplane (OAuth device flow)
|
||||||
|
|
||||||
|
Floatplane's own apps and third-party clients (Hydravion, Wasserflug,
|
||||||
|
floatplane-downloader) log in through an OAuth 2.0 device flow: you open a
|
||||||
|
link, enter a short code, and the plugin receives tokens that renew
|
||||||
|
themselves for about 90 days.
|
||||||
|
|
||||||
|
This requires a **client id issued by Floatplane** for this plugin. There is
|
||||||
|
no self-service registration; ask Floatplane (for example through their
|
||||||
|
community Discord). Without a registered id the button reports
|
||||||
|
`invalid_client`.
|
||||||
|
|
||||||
|
If you have one: enter it under **OAuth client id**, press **Login with
|
||||||
|
Floatplane**, open the link shown (or go to `floatplane.com/link`) and enter
|
||||||
|
the code. The page polls until Floatplane confirms and then shows *Logged in
|
||||||
|
as …*. The OAuth session replaces the cookie. **Forget OAuth session** drops
|
||||||
|
the tokens again. Client ids that require DPoP-bound tokens are not
|
||||||
|
supported.
|
||||||
|
|
||||||
|
## Browse and play
|
||||||
|
|
||||||
|
The **Floatplane** tile appears under *My Media*. It opens a folder per
|
||||||
|
creator — your subscriptions plus anything listed under *Extra creators*.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Each creator contains:
|
||||||
|
|
||||||
|
- **All videos** — every video from all of the creator's sub-channels in
|
||||||
|
release order, newest first (up to the number set in the settings).
|
||||||
|
- **One folder per sub-channel** (for example Linus Tech Tips, TechLinked,
|
||||||
|
ShortCircuit) with that channel's newest videos.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Videos carry the Floatplane thumbnail, the post text as description, the
|
||||||
|
duration and release date, the sub-channel as *Genre* and the creator as
|
||||||
|
*Studio*. Press **Play** to watch, or use the tick to mark a video as
|
||||||
|
played.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Good to know:
|
||||||
|
|
||||||
|
- **Order.** The plugin gives every video a sort name derived from its
|
||||||
|
release time, so the default *Name* sort of all Jellyfin clients lists
|
||||||
|
newest first — no need to change the sort in every folder. Switching the
|
||||||
|
sort direction flips it to oldest first. (The A–Z jump bar on the right of
|
||||||
|
the web client therefore does nothing useful in these folders.)
|
||||||
|
- **Watched state.** A video exists twice — in *All videos* and in its
|
||||||
|
sub-channel folder. The plugin mirrors played/resume state between the two
|
||||||
|
copies automatically.
|
||||||
|
- **Home screen.** *Recently Added in Floatplane* shows the newest videos
|
||||||
|
across creators; *Continue Watching* shows partially watched videos with
|
||||||
|
their thumbnails.
|
||||||
|
- **Search.** Jellyfin's search finds every video the plugin has listed at
|
||||||
|
least once. Floatplane's own search can be tried on the settings page
|
||||||
|
(*Search test*).
|
||||||
|
- **Locked posts.** Posts above your subscription tier are hidden by
|
||||||
|
default; with *Show posts above my tier* they appear with a 🔒 in the
|
||||||
|
name and will not play.
|
||||||
|
- **Playback and quality.** On play, the plugin asks Floatplane for the
|
||||||
|
stream (this endpoint is rate-limited to about two requests per minute;
|
||||||
|
the answer is cached for six hours) and serves a rewritten HLS playlist
|
||||||
|
from your Jellyfin server: the encryption key is fetched with your session,
|
||||||
|
the video segments come straight from Floatplane's CDN, and Jellyfin's
|
||||||
|
ffmpeg copies the H.264/AAC stream to the client without re-encoding.
|
||||||
|
The available quality levels appear as *Versions* in the player, preferred
|
||||||
|
quality first, up to *Highest quality offered*. 4K needs the matching
|
||||||
|
Floatplane tier. *Progressive MP4 versions* is an experimental alternative
|
||||||
|
that has not been verified yet.
|
||||||
|
|
||||||
|
## Settings reference
|
||||||
|
|
||||||
|
Dashboard → Plugins → Floatplane, in page order.
|
||||||
|
|
||||||
|
**Status block** — plugin version, which session is configured (cookie or
|
||||||
|
OAuth) and whether Floatplane accepted it, the creators and sub-channels
|
||||||
|
currently loaded, cache counters and the last refresh time.
|
||||||
|
|
||||||
|
### Session
|
||||||
|
|
||||||
|
| Setting | What it does |
|
||||||
|
|---------|--------------|
|
||||||
|
| **sails.sid cookie** | The Floatplane session cookie (see [Option 1](#option-1--paste-the-session-cookie-works-today)). Needed for subscriptions and playback. |
|
||||||
|
| **Validate cookie** | Checks the pasted value against Floatplane without saving it and shows username and subscriptions. |
|
||||||
|
| **OAuth client id** | Client id issued by Floatplane for this plugin (see [Option 2](#option-2--login-with-floatplane-oauth-device-flow)). |
|
||||||
|
| **Login with Floatplane** | Starts the device flow with that client id and shows the link and code to enter. |
|
||||||
|
| **Forget OAuth session** | Removes the stored OAuth tokens (shown only while an OAuth session exists). |
|
||||||
|
|
||||||
|
### Catalogue
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
|---------|---------|--------------|
|
||||||
|
| **Extra creators** | empty | Comma-separated Floatplane url-names (the part after `floatplane.com/channel/`, e.g. `linustechtips`) to list in addition to your subscriptions — or instead of them when you are not logged in. Listings are public; playback still needs a session. |
|
||||||
|
| **Videos listed in "All videos"** | 200 | How many of the newest videos the *All videos* folder contains. Floatplane returns 20 per request, so 200 = 10 requests per creator at refresh. |
|
||||||
|
| **Videos listed per sub-channel** | 100 | Same, per sub-channel folder. |
|
||||||
|
| **Show posts above my tier (locked)** | off | Include posts your subscription cannot access, marked with 🔒. |
|
||||||
|
| **Listing cache (minutes)** | 30 | How long the plugin keeps a folder listing before asking Floatplane again. |
|
||||||
|
| **Automatic refresh (hours)** | 6 | Interval of the *Refresh Floatplane catalogue* scheduled task. |
|
||||||
|
| **Refresh now** | — | Drops the caches, reloads creators and sub-channels and makes Jellyfin re-list the folders. Also happens after every **Save**. |
|
||||||
|
|
||||||
|
### Playback
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
|---------|---------|--------------|
|
||||||
|
| **Preferred quality** | 1080p | The version listed first and played by default. |
|
||||||
|
| **Highest quality offered (pixels)** | 1080 | Versions taller than this are not offered at all (keeps 4K out of the list unless you have that tier). |
|
||||||
|
| **Delivery lookups per minute** | 2 | Budget for Floatplane's stream-info endpoint. Raise it only if Floatplane changes its rate limit. |
|
||||||
|
| **Proxy base URL** | empty | How Jellyfin's own ffmpeg reaches the plugin's stream endpoints, e.g. `http://127.0.0.1:8096`. Empty uses Jellyfin's local address, which is right for almost every setup. |
|
||||||
|
| **Also offer progressive MP4 versions (experimental)** | off | Adds an MP4 version per video using Floatplane's download delivery. Untested; may not play. |
|
||||||
|
|
||||||
|
**Save** writes everything and triggers a refresh.
|
||||||
|
|
||||||
|
### Search test
|
||||||
|
|
||||||
|
Pick a creator, type a query and press **Search** to run the query through
|
||||||
|
Floatplane's search API and see the matching videos (date, sub-channel,
|
||||||
|
title, length). This is a diagnostic tool; it does not change anything.
|
||||||
|
|
||||||
|
## Scheduled task
|
||||||
|
|
||||||
|
The plugin registers **Refresh Floatplane catalogue** under **Dashboard →
|
||||||
|
Scheduled Tasks**. It runs at server start and every *Automatic refresh*
|
||||||
|
hours, reloads creators and sub-channels, pre-fetches the newest videos and
|
||||||
|
tells Jellyfin to re-list the channel. You can run it by hand from that page,
|
||||||
|
or press *Refresh now* on the settings page.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause / fix |
|
||||||
|
|---------|-------------|
|
||||||
|
| Playing a video fails with *NoCompatibleStream* or the player never starts; the log says *Cannot play Floatplane post … You must be logged-in* | No session or an expired one. Paste a fresh cookie (or log in with Floatplane) and Save. |
|
||||||
|
| Playback fails right after several quick starts; log mentions 429 or *rate-limited* | Floatplane's stream-info endpoint allows about two lookups per minute. Wait a minute and try again; results are cached afterwards. |
|
||||||
|
| New videos do not show up | Press *Refresh now* (or run the scheduled task). The plugin caches listings for *Listing cache* minutes and Jellyfin caches channel listings for three hours; a refresh invalidates both. |
|
||||||
|
| Creators missing although the cookie is valid | Press *Refresh now*; check the status block for an error message. Creators without a video post yet are skipped. |
|
||||||
|
| Everything is marked 🔒 | You are browsing without a session, or the session is rejected: without login Floatplane reports every post as locked. |
|
||||||
|
| *Login with Floatplane* says `invalid_client` | The client id is not registered with Floatplane. Use the cookie method until you have one. |
|
||||||
|
| Plugin shows *NotSupported* or *Malfunctioned* | Jellyfin version mismatch (this build is for 10.10.x) or a broken plugin folder. Check the server log. |
|
||||||
|
|
||||||
|
**Logs.** Everything the plugin writes is tagged `Jellyfin.Plugin.Floatplane`
|
||||||
|
in Jellyfin's server log (Dashboard → Logs, or `log/log_*.log` in the data
|
||||||
|
directory — `/config/log/` in the linuxserver.io image). For more detail,
|
||||||
|
copy `logging.default.json` to `logging.json` in the configuration
|
||||||
|
directory and add
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Jellyfin.Plugin.Floatplane": "Debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
under `Serilog → MinimumLevel → Override`, then restart.
|
||||||
|
|
||||||
|
**Paths (linuxserver.io image).** Plugins live in `/config/data/plugins/`,
|
||||||
|
the plugin configuration in `/config/data/plugins/configurations/Floatplane.xml`,
|
||||||
|
logs in `/config/log/`. The official image uses `/config/plugins/` and
|
||||||
|
`/config/log/`.
|
||||||
|
|
||||||
|
## Privacy and security
|
||||||
|
|
||||||
|
- The session cookie or OAuth tokens are stored in plain XML in Jellyfin's
|
||||||
|
plugin configuration folder, readable by Jellyfin administrators and
|
||||||
|
anyone with access to that directory. Treat it like a password.
|
||||||
|
- Stream URLs handed to the player are signed with a per-process HMAC
|
||||||
|
secret, so the plugin's stream endpoints cannot be used as a general
|
||||||
|
relay; they only serve what the plugin itself selected.
|
||||||
|
- Requests to Floatplane carry a `Jellyfin-Floatplane/<version>` User-Agent
|
||||||
|
so Floatplane can identify the client. The plugin keeps to the
|
||||||
|
documented rate limits.
|
||||||
|
- This is an unofficial client built on Floatplane's community-documented
|
||||||
|
API and is not affiliated with or endorsed by Floatplane Media Inc. Use it
|
||||||
|
for personal viewing with your own subscription.
|
||||||
@@ -1,26 +1,52 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Manual install/upgrade of the Floatplane plugin on a host that runs the
|
# Install or upgrade the Floatplane plugin from the Gitea release.
|
||||||
# linuxserver/jellyfin container (plugins live in /config/data/plugins/ inside it).
|
# Works for a Jellyfin that runs in Docker (official image or linuxserver.io image) or
|
||||||
|
# natively (systemd). Nothing is changed before the summary is printed; --dry-run stops there.
|
||||||
#
|
#
|
||||||
# curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install-pi5.sh | sh
|
# curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh
|
||||||
# sh install-pi5.sh --dry-run # only print what would happen
|
# curl -fsSL .../install.sh | sh -s -- --dry-run
|
||||||
#
|
#
|
||||||
# Environment (all optional):
|
# Environment (all optional):
|
||||||
# TAG release to install: latest (default) or vX.Y.Z
|
# TAG release to install: latest (default) or vX.Y.Z
|
||||||
# JELLYFIN_CONFIG_DIR host path mounted as the container's /config (default /srv/docker/jellyfin,
|
# JELLYFIN_CONTAINER docker container name (default: jellyfin; empty = native install)
|
||||||
# i.e. ${DOCKER_PATH}/jellyfin on the Pi5)
|
# JELLYFIN_CONFIG_DIR host path of the container's /config (default: read from `docker inspect`),
|
||||||
# JELLYFIN_CONTAINER container name to restart (default jellyfin); empty = don't restart
|
# or for native installs the data dir (default /var/lib/jellyfin)
|
||||||
# PUID / PGID owner for the plugin files (default: read from the container, else 1000:1000)
|
# PLUGINS_DIR override the plugins directory outright (skips the detection below)
|
||||||
|
# PUID / PGID owner for the plugin files (default: from the container's env, else the dir owner)
|
||||||
#
|
#
|
||||||
# NOTE (Pi5): this restarts the jellyfin container and writes under /srv/docker, so run it
|
# Plugin directory detection:
|
||||||
# as a user that may do that (Bjorn, typically with sudo). Nothing is changed before the
|
# linuxserver/jellyfin -> <config>/data/plugins (JELLYFIN_DATA_DIR=/config/data)
|
||||||
# summary below is printed; pass --dry-run to stop there.
|
# jellyfin/jellyfin -> <config>/plugins (JELLYFIN_DATA_DIR=/config)
|
||||||
|
# native -> /var/lib/jellyfin/plugins
|
||||||
|
#
|
||||||
|
# Needs: curl, unzip, md5sum (or md5), and jq if available (sed fallback otherwise).
|
||||||
|
# Run it as a user that may write to the plugin directory and restart Jellyfin (sudo, typically).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
REPO_URL="https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane"
|
REPO_URL="https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane"
|
||||||
TAG="${TAG:-latest}"
|
TAG="${TAG:-latest}"
|
||||||
JELLYFIN_CONFIG_DIR="${JELLYFIN_CONFIG_DIR:-/srv/docker/jellyfin}"
|
|
||||||
JELLYFIN_CONTAINER="${JELLYFIN_CONTAINER-jellyfin}"
|
JELLYFIN_CONTAINER="${JELLYFIN_CONTAINER-jellyfin}"
|
||||||
|
JELLYFIN_CONFIG_DIR="${JELLYFIN_CONFIG_DIR:-}"
|
||||||
|
PLUGINS_DIR="${PLUGINS_DIR:-}"
|
||||||
|
|
||||||
|
# --- locate the plugins directory -------------------------------------------------
|
||||||
|
if [ -z "$PLUGINS_DIR" ]; then
|
||||||
|
if [ -n "$JELLYFIN_CONTAINER" ] && command -v docker >/dev/null 2>&1 && docker inspect "$JELLYFIN_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [ -z "$JELLYFIN_CONFIG_DIR" ]; then
|
||||||
|
JELLYFIN_CONFIG_DIR=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/config"}}{{.Source}}{{end}}{{end}}' "$JELLYFIN_CONTAINER" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
[ -n "$JELLYFIN_CONFIG_DIR" ] || { echo "error: cannot find the /config mount of container $JELLYFIN_CONTAINER; set JELLYFIN_CONFIG_DIR or PLUGINS_DIR" >&2; exit 1; }
|
||||||
|
DATA_DIR=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$JELLYFIN_CONTAINER" 2>/dev/null | sed -n 's/^JELLYFIN_DATA_DIR=//p' | head -n1)
|
||||||
|
case "$DATA_DIR" in
|
||||||
|
/config/*) PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/${DATA_DIR#/config/}/plugins" ;; # linuxserver: /config/data
|
||||||
|
*) if [ -d "$JELLYFIN_CONFIG_DIR/data/plugins" ]; then PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/data/plugins"; else PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/plugins"; fi ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
[ -n "$JELLYFIN_CONTAINER" ] && echo "note: container '$JELLYFIN_CONTAINER' not found via docker; assuming a native install (set JELLYFIN_CONTAINER= to silence)" >&2
|
||||||
|
JELLYFIN_CONTAINER=""
|
||||||
|
PLUGINS_DIR="${JELLYFIN_CONFIG_DIR:-/var/lib/jellyfin}/plugins"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
for a in "$@"; do
|
for a in "$@"; do
|
||||||
case "$a" in
|
case "$a" in
|
||||||
@@ -54,7 +80,6 @@ else
|
|||||||
fi
|
fi
|
||||||
[ -n "$VERSION" ] && [ -n "$SOURCE_URL" ] || { echo "error: could not read version/sourceUrl from manifest" >&2; exit 1; }
|
[ -n "$VERSION" ] && [ -n "$SOURCE_URL" ] || { echo "error: could not read version/sourceUrl from manifest" >&2; exit 1; }
|
||||||
|
|
||||||
PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/data/plugins"
|
|
||||||
DEST="$PLUGINS_DIR/Floatplane_$VERSION"
|
DEST="$PLUGINS_DIR/Floatplane_$VERSION"
|
||||||
|
|
||||||
# owner: the container's PUID/PGID if we can read them, else 1000:1000
|
# owner: the container's PUID/PGID if we can read them, else 1000:1000
|
||||||
@@ -75,7 +100,7 @@ echo " md5: $CHECKSUM"
|
|||||||
echo " install: $DEST/"
|
echo " install: $DEST/"
|
||||||
[ -n "$OLD" ] && echo " remove: $(echo "$OLD" | tr '\n' ' ')"
|
[ -n "$OLD" ] && echo " remove: $(echo "$OLD" | tr '\n' ' ')"
|
||||||
echo " owner: $OWNER_UID:$OWNER_GID"
|
echo " owner: $OWNER_UID:$OWNER_GID"
|
||||||
if [ -n "$JELLYFIN_CONTAINER" ]; then echo " restart: docker restart $JELLYFIN_CONTAINER"; else echo " restart: (skipped, JELLYFIN_CONTAINER empty)"; fi
|
if [ -n "$JELLYFIN_CONTAINER" ]; then echo " restart: docker restart $JELLYFIN_CONTAINER"; elif command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files jellyfin.service >/dev/null 2>&1; then echo " restart: systemctl restart jellyfin"; else echo " restart: (none - restart Jellyfin yourself)"; fi
|
||||||
echo
|
echo
|
||||||
|
|
||||||
if [ "$DRY_RUN" = 1 ]; then
|
if [ "$DRY_RUN" = 1 ]; then
|
||||||
@@ -84,7 +109,7 @@ if [ "$DRY_RUN" = 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -d "$PLUGINS_DIR" ]; then
|
if [ ! -d "$PLUGINS_DIR" ]; then
|
||||||
echo "error: $PLUGINS_DIR does not exist - is JELLYFIN_CONFIG_DIR right? (linuxserver image keeps data in <config>/data)" >&2
|
echo "error: $PLUGINS_DIR does not exist - set PLUGINS_DIR (linuxserver image: <config>/data/plugins, official image: <config>/plugins, native: /var/lib/jellyfin/plugins)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -33,15 +33,17 @@ dotnet publish Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj \
|
|||||||
PLUGIN_DIR="build/${NAME}_${VERSION}"
|
PLUGIN_DIR="build/${NAME}_${VERSION}"
|
||||||
mkdir -p "$PLUGIN_DIR"
|
mkdir -p "$PLUGIN_DIR"
|
||||||
cp build/publish/Jellyfin.Plugin.Floatplane.dll "$PLUGIN_DIR/"
|
cp build/publish/Jellyfin.Plugin.Floatplane.dll "$PLUGIN_DIR/"
|
||||||
|
cp Jellyfin.Plugin.Floatplane/Resources/thumb.png "$PLUGIN_DIR/thumb.png" # shown in Dashboard -> Plugins
|
||||||
|
cp Jellyfin.Plugin.Floatplane/Resources/thumb.png build/thumb.png # release asset for manifest imageUrl
|
||||||
jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \
|
jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \
|
||||||
--arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \
|
--arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \
|
||||||
--arg abi "$ABI" --arg ts "$TS" \
|
--arg abi "$ABI" --arg ts "$TS" \
|
||||||
'{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat,
|
'{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat,
|
||||||
version:$ver, changelog:$cl, targetAbi:$abi, timestamp:$ts, status:"Active", autoUpdate:true,
|
version:$ver, changelog:$cl, targetAbi:$abi, timestamp:$ts, status:"Active", autoUpdate:true,
|
||||||
imagePath:"", assemblies:["Jellyfin.Plugin.Floatplane.dll"]}' > "$PLUGIN_DIR/meta.json"
|
imagePath:"thumb.png", assemblies:["Jellyfin.Plugin.Floatplane.dll"]}' > "$PLUGIN_DIR/meta.json"
|
||||||
|
|
||||||
ZIP="build/${SLUG}_${VERSION}.zip"
|
ZIP="build/${SLUG}_${VERSION}.zip"
|
||||||
(cd "$PLUGIN_DIR" && zip -q -j "../../$ZIP" Jellyfin.Plugin.Floatplane.dll meta.json)
|
(cd "$PLUGIN_DIR" && zip -q -j "../../$ZIP" Jellyfin.Plugin.Floatplane.dll meta.json thumb.png)
|
||||||
MD5=$(md5sum "$ZIP" | cut -d' ' -f1)
|
MD5=$(md5sum "$ZIP" | cut -d' ' -f1)
|
||||||
echo "$MD5 $(basename "$ZIP")" > "$ZIP.md5"
|
echo "$MD5 $(basename "$ZIP")" > "$ZIP.md5"
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ if [ -n "$BASE_URL" ]; then
|
|||||||
jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \
|
jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \
|
||||||
--arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \
|
--arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \
|
||||||
--arg abi "$ABI" --arg ts "$TS" --arg md5 "$MD5" --arg url "${BASE_URL%/}/$(basename "$ZIP")" \
|
--arg abi "$ABI" --arg ts "$TS" --arg md5 "$MD5" --arg url "${BASE_URL%/}/$(basename "$ZIP")" \
|
||||||
'[{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat, imageUrl:"",
|
--arg img "${BASE_URL%/}/thumb.png" \
|
||||||
|
'[{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat, imageUrl:$img,
|
||||||
versions:[{version:$ver, changelog:$cl, targetAbi:$abi, sourceUrl:$url, checksum:$md5, timestamp:$ts}]}]' \
|
versions:[{version:$ver, changelog:$cl, targetAbi:$abi, sourceUrl:$url, checksum:$md5, timestamp:$ts}]}]' \
|
||||||
> build/manifest.json
|
> build/manifest.json
|
||||||
fi
|
fi
|
||||||
|
|||||||