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; /// Helpers for the dashboard configuration page (admin only). [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 _logger; public FloatplaneAdminController(FloatplaneApiClient api, FloatplaneAuthService auth, CatalogService catalog, DeliveryResolver delivery, ILogger logger) { _api = api; _auth = auth; _catalog = catalog; _delivery = delivery; _logger = logger; } // ───────────────────────────── OAuth device flow ───────────────────────────── /// Starts an OAuth device login with the given client id (saved to the configuration). [HttpPost("OAuth/Start")] public async Task> 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}"); } } /// Polls the pending device login once. [HttpGet("OAuth/Poll")] public async Task> 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; } /// Drops the stored OAuth tokens. [HttpPost("OAuth/Logout")] public ActionResult LogoutOAuth() { FloatplaneAuthService.Logout(); _catalog.Invalidate(); return GetStatus(); } /// Validates a session cookie (without saving it) and lists the subscriptions it has. [HttpPost("Session/Validate")] public async Task> 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 }; } } /// Current plugin state for the settings page. [HttpGet("Status")] public ActionResult 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 }; } /// Drops caches and reloads creators (post lists refill on browse). [HttpPost("Refresh")] public async Task> Refresh() { _catalog.Invalidate(); await _catalog.GetCreatorsAsync(true, HttpContext.RequestAborted).ConfigureAwait(false); return GetStatus(); } /// Runs a Floatplane search (public endpoint) — for mapping/testing the search API. [HttpGet("Search")] public async Task>> 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(); } /// Tags Floatplane knows for a creator (public endpoint). [HttpGet("Tags")] public Task> 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 Subscriptions { get; set; } = new(); } public class PluginStatus { public string Version { get; set; } = string.Empty; public bool HasSessionCookie { get; set; } /// "oauth", "cookie" or "none". 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 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 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; } }