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
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>
This commit is contained in:
@@ -18,18 +18,90 @@ namespace Jellyfin.Plugin.Floatplane.Api;
|
||||
public class FloatplaneAdminController : ControllerBase
|
||||
{
|
||||
private readonly FloatplaneApiClient _api;
|
||||
private readonly FloatplaneAuthService _auth;
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly DeliveryResolver _delivery;
|
||||
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;
|
||||
_auth = auth;
|
||||
_catalog = catalog;
|
||||
_delivery = delivery;
|
||||
_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>
|
||||
[HttpPost("Session/Validate")]
|
||||
public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request)
|
||||
@@ -78,6 +150,8 @@ public class FloatplaneAdminController : ControllerBase
|
||||
{
|
||||
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
|
||||
HasSessionCookie = _api.HasSession,
|
||||
SessionKind = _api.SessionKind,
|
||||
OAuthClientId = Plugin.Instance?.Configuration.OAuthClientId ?? string.Empty,
|
||||
SessionState = _catalog.SessionState ?? "unknown",
|
||||
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
|
||||
LastError = _catalog.LastError,
|
||||
@@ -131,6 +205,11 @@ public class FloatplaneAdminController : ControllerBase
|
||||
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
public class OAuthStartRequest
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
}
|
||||
|
||||
public class SessionValidationRequest
|
||||
{
|
||||
public string? Cookie { get; set; }
|
||||
@@ -155,6 +234,11 @@ public class PluginStatus
|
||||
|
||||
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 SessionUsername { get; set; } = string.Empty;
|
||||
|
||||
@@ -27,13 +27,15 @@ public class FloatplaneApiClient
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<FloatplaneApiClient> _logger;
|
||||
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())
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
@@ -41,8 +43,11 @@ public class FloatplaneApiClient
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>True when a session cookie is configured (not necessarily valid).</summary>
|
||||
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie);
|
||||
/// <summary>True when a session (cookie or OAuth refresh token) is configured — not necessarily valid.</summary>
|
||||
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 ─────────────────────────────
|
||||
|
||||
@@ -177,7 +182,7 @@ public class FloatplaneApiClient
|
||||
public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds });
|
||||
using var request = BuildRequest(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null);
|
||||
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");
|
||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
@@ -205,7 +210,7 @@ public class FloatplaneApiClient
|
||||
/// </summary>
|
||||
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 + "/");
|
||||
using var response = await SendAsync(request, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
@@ -228,6 +233,23 @@ public class FloatplaneApiClient
|
||||
|
||||
// ───────────────────────────── 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
|
||||
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
|
||||
280
Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs
Normal file
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;
|
||||
|
||||
/// <summary>"oauth", "cookie" or "none".</summary>
|
||||
public string SessionKind => _api.SessionKind;
|
||||
|
||||
/// <summary>Session state as last observed (null = not checked yet).</summary>
|
||||
public string? SessionState { get; private set; }
|
||||
|
||||
@@ -83,7 +86,16 @@ public class CatalogService
|
||||
var done = 0;
|
||||
foreach (var creator in creators)
|
||||
{
|
||||
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { string.Empty };
|
||||
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)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -240,6 +252,22 @@ public class CatalogService
|
||||
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>
|
||||
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
|
||||
{
|
||||
private const string CreatorPrefix = "creator:";
|
||||
private const string AllPrefix = "all:";
|
||||
private const string ChannelPrefix = "channel:";
|
||||
private const string PostPrefix = "post:";
|
||||
private const string ChannelPostPrefix = "cpost:";
|
||||
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly SortNameUpdater _sortNames;
|
||||
private readonly ILogger<FloatplaneChannel> _logger;
|
||||
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;
|
||||
_appHost = appHost;
|
||||
_sortNames = sortNames;
|
||||
_logger = logger;
|
||||
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
}
|
||||
@@ -82,7 +86,9 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
};
|
||||
|
||||
/// <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 />
|
||||
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);
|
||||
}
|
||||
else if (folderId.StartsWith(AllPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
items = await GetAllVideoItemsAsync(folderId[AllPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var rest = folderId[ChannelPrefix.Length..];
|
||||
@@ -125,6 +135,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
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);
|
||||
var total = items.Count;
|
||||
if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
|
||||
@@ -164,12 +180,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
/// <inheritdoc />
|
||||
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>();
|
||||
}
|
||||
|
||||
var postId = id[PostPrefix.Length..];
|
||||
PostModel post;
|
||||
try
|
||||
{
|
||||
@@ -259,10 +275,23 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
|
||||
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,
|
||||
Name = ch.Title,
|
||||
@@ -271,25 +300,62 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
Overview = ch.About,
|
||||
ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname
|
||||
}).ToList();
|
||||
}));
|
||||
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)
|
||||
{
|
||||
var creator = _catalog.FindCreator(creatorId);
|
||||
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
|
||||
// Anonymous listings flag everything as inaccessible; only mark locks when logged in.
|
||||
var markLocked = _catalog.HasSession;
|
||||
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
||||
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>
|
||||
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 item = new ChannelItemInfo
|
||||
{
|
||||
Id = PostPrefix + post.Id,
|
||||
Id = idPrefix + post.Id,
|
||||
Name = name,
|
||||
Type = ChannelItemType.Media,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
|
||||
120
Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs
Normal file
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
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>
|
||||
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;
|
||||
|
||||
/// <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>
|
||||
public bool ShowLockedPosts { get; set; }
|
||||
|
||||
@@ -59,6 +77,9 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>Hours between automatic catalogue refreshes (scheduled task).</summary>
|
||||
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>
|
||||
public bool SyncProgressToFloatplane { get; set; }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,23 @@
|
||||
<button is="emby-button" type="button" id="btnValidate" class="raised"><span>Validate cookie</span></button>
|
||||
<span id="validateResult" class="fieldDescription" style="margin-left:1em"></span>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
<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" />
|
||||
<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 class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
@@ -126,8 +148,8 @@
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
|
||||
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "CatalogCacheMinutes", "SyncIntervalHours",
|
||||
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl"];
|
||||
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "MaxPostsAllVideos", "CatalogCacheMinutes", "SyncIntervalHours",
|
||||
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl", "OAuthClientId"];
|
||||
var checks = ["ShowLockedPosts", "EnableMp4Sources"];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
@@ -138,15 +160,17 @@
|
||||
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) {
|
||||
var lines = [];
|
||||
lines.push("Plugin " + esc(s.Version));
|
||||
var kind = s.SessionKind === "oauth" ? "OAuth" : "cookie";
|
||||
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") {
|
||||
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") {
|
||||
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 {
|
||||
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)"));
|
||||
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);
|
||||
@@ -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() {
|
||||
el("fpStatus").textContent = "Refreshing…";
|
||||
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
||||
@@ -238,6 +300,8 @@
|
||||
el("FloatplaneConfigForm").addEventListener("submit", saveConfig);
|
||||
el("btnValidate").addEventListener("click", validate);
|
||||
el("btnRefresh").addEventListener("click", refresh);
|
||||
el("btnOAuth").addEventListener("click", oauthStart);
|
||||
el("btnOAuthLogout").addEventListener("click", oauthLogout);
|
||||
el("btnSearch").addEventListener("click", search);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Resources\logo.png" />
|
||||
<EmbeddedResource Include="Resources\logo.png" />
|
||||
<None Remove="Resources\thumb.png" />
|
||||
<EmbeddedResource Include="Resources\thumb.png" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -38,13 +38,19 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
});
|
||||
|
||||
serviceCollection.AddSingleton<FloatplaneAuthService>();
|
||||
serviceCollection.AddSingleton<FloatplaneApiClient>();
|
||||
serviceCollection.AddSingleton<CatalogService>();
|
||||
serviceCollection.AddSingleton<DeliveryResolver>();
|
||||
|
||||
// One instance: the status endpoint and the sync task must see the same caches
|
||||
// as the channel Jellyfin serves from.
|
||||
serviceCollection.AddSingleton<SortNameUpdater>();
|
||||
serviceCollection.AddSingleton<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>();
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 49 KiB |
BIN
Jellyfin.Plugin.Floatplane/Resources/thumb.png
Normal file
BIN
Jellyfin.Plugin.Floatplane/Resources/thumb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -2,7 +2,14 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
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 Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -14,12 +21,23 @@ namespace Jellyfin.Plugin.Floatplane.Tasks;
|
||||
/// </summary>
|
||||
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 SortNameUpdater _sortNames;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
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;
|
||||
_sortNames = sortNames;
|
||||
_libraryManager = libraryManager;
|
||||
_providerManager = providerManager;
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,6 +68,58 @@ public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
|
||||
_logger.LogInformation("Floatplane sync started");
|
||||
await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation);
|
||||
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 />
|
||||
|
||||
Reference in New Issue
Block a user