Files
jellyfin-plugin-floatplane/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs
claude bf476134c2
Some checks failed
release / build-release (push) Has been cancelled
v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs
- 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>
2026-10-25 16:36:32 +01:00

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; }
}