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>
286 lines
9.8 KiB
C#
286 lines
9.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.Floatplane.Channel;
|
|
using Jellyfin.Plugin.Floatplane.Playback;
|
|
using MediaBrowser.Common.Api;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Floatplane.Api;
|
|
|
|
/// <summary>Helpers for the dashboard configuration page (admin only).</summary>
|
|
[ApiController]
|
|
[Route("Floatplane")]
|
|
[Authorize(Policy = Policies.RequiresElevation)]
|
|
public class FloatplaneAdminController : ControllerBase
|
|
{
|
|
private readonly FloatplaneApiClient _api;
|
|
private readonly FloatplaneAuthService _auth;
|
|
private readonly CatalogService _catalog;
|
|
private readonly DeliveryResolver _delivery;
|
|
private readonly 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)
|
|
{
|
|
var cookie = request.Cookie?.Trim() ?? string.Empty;
|
|
if (cookie.StartsWith("sails.sid=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
cookie = cookie["sails.sid=".Length..];
|
|
}
|
|
|
|
cookie = cookie.Trim().TrimEnd(';');
|
|
if (cookie.Length == 0)
|
|
{
|
|
return new SessionValidationResult { Ok = false, Message = "Cookie is empty" };
|
|
}
|
|
|
|
try
|
|
{
|
|
var ct = HttpContext.RequestAborted;
|
|
var self = await _api.GetSelfAsync(ct, cookie).ConfigureAwait(false);
|
|
var subs = await _api.GetSubscriptionsAsync(ct, cookie).ConfigureAwait(false);
|
|
return new SessionValidationResult
|
|
{
|
|
Ok = true,
|
|
Username = self.Username,
|
|
Cookie = cookie,
|
|
Subscriptions = subs.Select(s => (s.Plan?.Title ?? "?") + " (" + s.Creator + ")").ToList(),
|
|
Message = $"Logged in as {self.Username}, {subs.Count} active subscription(s)"
|
|
};
|
|
}
|
|
catch (FloatplaneAuthException ex)
|
|
{
|
|
return new SessionValidationResult { Ok = false, Message = "Floatplane rejected the cookie: " + ex.Message };
|
|
}
|
|
catch (FloatplaneApiException ex)
|
|
{
|
|
return new SessionValidationResult { Ok = false, Message = "Floatplane error: " + ex.Message };
|
|
}
|
|
}
|
|
|
|
/// <summary>Current plugin state for the settings page.</summary>
|
|
[HttpGet("Status")]
|
|
public ActionResult<PluginStatus> GetStatus()
|
|
{
|
|
return new PluginStatus
|
|
{
|
|
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
|
|
HasSessionCookie = _api.HasSession,
|
|
SessionKind = _api.SessionKind,
|
|
OAuthClientId = Plugin.Instance?.Configuration.OAuthClientId ?? string.Empty,
|
|
SessionState = _catalog.SessionState ?? "unknown",
|
|
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
|
|
LastError = _catalog.LastError,
|
|
Generation = _catalog.Generation,
|
|
LastRefresh = _catalog.LastRefresh == DateTimeOffset.MinValue ? null : _catalog.LastRefresh,
|
|
Creators = _catalog.Creators.Select(c => new CreatorSummary
|
|
{
|
|
Id = c.Id,
|
|
Title = c.Title,
|
|
Urlname = c.Urlname,
|
|
Channels = c.Channels.Select(ch => ch.Title).ToList()
|
|
}).ToList(),
|
|
CachedPostLists = _catalog.CachedPostListCount,
|
|
CachedDeliveries = _delivery.CachedEntries
|
|
};
|
|
}
|
|
|
|
/// <summary>Drops caches and reloads creators (post lists refill on browse).</summary>
|
|
[HttpPost("Refresh")]
|
|
public async Task<ActionResult<PluginStatus>> Refresh()
|
|
{
|
|
_catalog.Invalidate();
|
|
await _catalog.GetCreatorsAsync(true, HttpContext.RequestAborted).ConfigureAwait(false);
|
|
return GetStatus();
|
|
}
|
|
|
|
/// <summary>Runs a Floatplane search (public endpoint) — for mapping/testing the search API.</summary>
|
|
[HttpGet("Search")]
|
|
public async Task<ActionResult<List<SearchHit>>> Search(
|
|
[FromQuery] string creatorId,
|
|
[FromQuery] string? q,
|
|
[FromQuery] string? channelId,
|
|
[FromQuery] int limit = 20)
|
|
{
|
|
var posts = await _catalog.FetchPostsAsync(creatorId, channelId ?? string.Empty, Math.Clamp(limit, 1, 100), HttpContext.RequestAborted, q).ConfigureAwait(false);
|
|
return posts.Select(p => new SearchHit
|
|
{
|
|
Id = p.Id,
|
|
Title = p.Title,
|
|
Channel = p.Channel?.Title ?? p.Channel?.Id,
|
|
ReleaseDate = p.ReleaseDate,
|
|
DurationSeconds = p.DurationSeconds,
|
|
IsAccessible = p.IsAccessible,
|
|
Thumbnail = p.Thumbnail?.BestPath(400)
|
|
}).ToList();
|
|
}
|
|
|
|
/// <summary>Tags Floatplane knows for a creator (public endpoint).</summary>
|
|
[HttpGet("Tags")]
|
|
public Task<Dictionary<string, int>> Tags([FromQuery] string creatorId)
|
|
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
|
|
}
|
|
|
|
public class OAuthStartRequest
|
|
{
|
|
public string? ClientId { get; set; }
|
|
}
|
|
|
|
public class SessionValidationRequest
|
|
{
|
|
public string? Cookie { get; set; }
|
|
}
|
|
|
|
public class SessionValidationResult
|
|
{
|
|
public bool Ok { get; set; }
|
|
|
|
public string? Username { get; set; }
|
|
|
|
public string? Cookie { get; set; }
|
|
|
|
public string? Message { get; set; }
|
|
|
|
public List<string> Subscriptions { get; set; } = new();
|
|
}
|
|
|
|
public class PluginStatus
|
|
{
|
|
public string Version { get; set; } = string.Empty;
|
|
|
|
public bool HasSessionCookie { get; set; }
|
|
|
|
/// <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;
|
|
|
|
public string? LastError { get; set; }
|
|
|
|
public int Generation { get; set; }
|
|
|
|
public DateTimeOffset? LastRefresh { get; set; }
|
|
|
|
public List<CreatorSummary> Creators { get; set; } = new();
|
|
|
|
public int CachedPostLists { get; set; }
|
|
|
|
public int CachedDeliveries { get; set; }
|
|
}
|
|
|
|
public class CreatorSummary
|
|
{
|
|
public string Id { get; set; } = string.Empty;
|
|
|
|
public string Title { get; set; } = string.Empty;
|
|
|
|
public string Urlname { get; set; } = string.Empty;
|
|
|
|
public List<string> Channels { get; set; } = new();
|
|
}
|
|
|
|
public class SearchHit
|
|
{
|
|
public string Id { get; set; } = string.Empty;
|
|
|
|
public string Title { get; set; } = string.Empty;
|
|
|
|
public string? Channel { get; set; }
|
|
|
|
public DateTimeOffset? ReleaseDate { get; set; }
|
|
|
|
public double DurationSeconds { get; set; }
|
|
|
|
public bool IsAccessible { get; set; }
|
|
|
|
public string? Thumbnail { get; set; }
|
|
}
|