From e1c17bfe351a74f7d7a39de16fcf16f9bffcb7e8 Mon Sep 17 00:00:00 2001 From: mattsigal Date: Sat, 27 Jun 2026 23:06:53 -0700 Subject: [PATCH 01/10] feat(custom-rows): implement server-side cache and proxy endpoints for custom rows --- backend/Api/CustomRowController.cs | 684 ++++++++++++++++++++++ backend/PluginServiceRegistrator.cs | 1 + backend/Services/CustomRowCacheService.cs | 170 ++++++ 3 files changed, 855 insertions(+) create mode 100644 backend/Api/CustomRowController.cs create mode 100644 backend/Services/CustomRowCacheService.cs diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs new file mode 100644 index 0000000..292adc1 --- /dev/null +++ b/backend/Api/CustomRowController.cs @@ -0,0 +1,684 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moonfin.Server.Services; + +namespace Moonfin.Server.Api; + +[ApiController] +[Route("Moonfin/CustomRows")] +public class CustomRowController : ControllerBase +{ + private readonly MoonfinSettingsService _settingsService; + private readonly CustomRowCacheService _cacheService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + + // Persistent file mapping Letterboxd slugs to TMDb IDs + private readonly string _letterboxdSlugsFilePath; + private static ConcurrentDictionary? _letterboxdSlugMap; + private static readonly SemaphoreSlim _slugFileLock = new(1, 1); + + public CustomRowController( + MoonfinSettingsService settingsService, + CustomRowCacheService cacheService, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _settingsService = settingsService; + _cacheService = cacheService; + _httpClientFactory = httpClientFactory; + _logger = logger; + + var dataPath = MoonfinPlugin.Instance?.DataFolderPath + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Jellyfin", "plugins", "Moonfin"); + + if (!Directory.Exists(dataPath)) + { + Directory.CreateDirectory(dataPath); + } + + _letterboxdSlugsFilePath = Path.Combine(dataPath, "letterboxd_slug_to_tmdb.json"); + } + + [HttpGet("Items")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetCustomRowItems( + [FromQuery] string source, + [FromQuery] string type, + [FromQuery] string @params, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(@params)) + { + return BadRequest(new { Error = "Missing required parameters: source, type, params" }); + } + + source = source.Trim().ToLowerInvariant(); + type = type.Trim().ToLowerInvariant(); + + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(new { Error = "User not authenticated" }); + } + + var paramHash = GetStringSha256Hash(@params); + var cacheKey = $"{source}:{type}:{paramHash}"; + + var cachedItems = _cacheService.TryGet(cacheKey, CacheTtl); + if (cachedItems != null) + { + return Ok(new CustomRowResponse + { + Success = true, + Items = cachedItems + }); + } + + try + { + var parsedParams = JsonSerializer.Deserialize>(@params) ?? new(); + List items = new(); + + switch (source) + { + case "mdblist": + items = await FetchMdbList(type, parsedParams, userId.Value, cancellationToken); + break; + case "tmdb": + items = await FetchTmdb(type, parsedParams, userId.Value, cancellationToken); + break; + case "letterboxd": + items = await FetchLetterboxd(type, parsedParams, userId.Value, cancellationToken); + break; + default: + return BadRequest(new { Error = $"Unsupported custom row source: {source}" }); + } + + _cacheService.Set(cacheKey, items); + await _cacheService.FlushAsync(); + + return Ok(new CustomRowResponse + { + Success = true, + Items = items + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to resolve custom row for source: {Source}, type: {Type}", source, type); + return Ok(new CustomRowResponse + { + Success = false, + Error = ex.Message + }); + } + } + + private async Task> FetchMdbList( + string type, + Dictionary paramsMap, + Guid userId, + CancellationToken cancellationToken) + { + var resolved = await _settingsService.GetResolvedProfileAsync(userId, "global"); + var apiKey = resolved?.MdblistApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + apiKey = MoonfinPlugin.Instance?.Configuration?.MdblistApiKey; + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("MDBList API key is not configured."); + } + + paramsMap.TryGetValue("username", out var username); + paramsMap.TryGetValue("listname", out var listname); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(listname)) + { + throw new ArgumentException("MDBList requires both username and listname parameters."); + } + + var url = $"https://api.mdblist.com/lists/{Uri.EscapeDataString(username)}/{Uri.EscapeDataString(listname)}/items?apikey={Uri.EscapeDataString(apiKey)}&limit=250"; + var client = CreateClient(); + + using var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"MDBList API returned status {(int)response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + var items = new List(); + JsonElement itemsArray; + + if (root.ValueKind == JsonValueKind.Array) + { + itemsArray = root; + } + else if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("movies", out var moviesProp) && moviesProp.ValueKind == JsonValueKind.Array) + { + itemsArray = moviesProp; + } + else + { + return items; + } + + int rank = 1; + foreach (var item in itemsArray.EnumerateArray()) + { + string? imdbId = null; + string? tmdbId = null; + + if (item.TryGetProperty("ids", out var idsObj)) + { + if (idsObj.TryGetProperty("imdb", out var imdbProp) && imdbProp.ValueKind == JsonValueKind.String) + { + imdbId = imdbProp.GetString(); + } + if (idsObj.TryGetProperty("tmdb", out var tmdbProp)) + { + tmdbId = tmdbProp.ValueKind == JsonValueKind.Number + ? tmdbProp.GetInt64().ToString() + : tmdbProp.GetString(); + } + } + + if (string.IsNullOrWhiteSpace(imdbId) && item.TryGetProperty("imdb_id", out var imdbIdProp) && imdbIdProp.ValueKind == JsonValueKind.String) + { + imdbId = imdbIdProp.GetString(); + } + + var title = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "Unknown" : "Unknown"; + + int? year = null; + if (item.TryGetProperty("release_year", out var yrProp) && yrProp.ValueKind == JsonValueKind.Number) + { + year = yrProp.GetInt32(); + } + + var mediaType = item.TryGetProperty("mediatype", out var mediaProp) ? mediaProp.GetString()?.ToLowerInvariant() : null; + var finalType = (mediaType == "show" || mediaType == "shows" || mediaType == "series" || mediaType == "tv") ? "Series" : "Movie"; + + string? posterUrl = null; + if (item.TryGetProperty("poster", out var pProp) && pProp.ValueKind == JsonValueKind.String) + { + posterUrl = pProp.GetString(); + } + else if (item.TryGetProperty("ids", out var idsVal) && idsVal.TryGetProperty("poster", out var idpProp) && idpProp.ValueKind == JsonValueKind.String) + { + posterUrl = idpProp.GetString(); + } + + items.Add(new CustomRowItem + { + Id = string.IsNullOrWhiteSpace(tmdbId) ? null : long.Parse(tmdbId), + Name = title, + Type = finalType, + ProductionYear = year, + Rank = rank++, + ProviderIds = new CustomRowItemProviderIds + { + Imdb = imdbId, + Tmdb = tmdbId + }, + PosterUrl = posterUrl + }); + } + + return items; + } + + private async Task> FetchTmdb( + string type, + Dictionary paramsMap, + Guid userId, + CancellationToken cancellationToken) + { + var resolved = await _settingsService.GetResolvedProfileAsync(userId, "global"); + var apiKey = resolved?.TmdbApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + apiKey = MoonfinPlugin.Instance?.Configuration?.TmdbApiKey; + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("TMDB API Key is not configured."); + } + + paramsMap.TryGetValue("id", out var listId); + if (string.IsNullOrWhiteSpace(listId)) + { + throw new ArgumentException("TMDB requires id parameter."); + } + + var isCollection = type == "movie_collection"; + var url = isCollection + ? $"https://api.themoviedb.org/3/collection/{Uri.EscapeDataString(listId)}" + : $"https://api.themoviedb.org/3/list/{Uri.EscapeDataString(listId)}"; + + var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ApplyTmdbAuth(request, apiKey); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"TMDB API returned status {(int)response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + var items = new List(); + int rank = 1; + + if (isCollection) + { + if (root.TryGetProperty("parts", out var partsProp) && partsProp.ValueKind == JsonValueKind.Array) + { + foreach (var part in partsProp.EnumerateArray()) + { + var partId = part.TryGetProperty("id", out var idProp) ? idProp.GetInt64().ToString() : null; + var title = part.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "Unknown" : "Unknown"; + var dateStr = part.TryGetProperty("release_date", out var rdProp) ? rdProp.GetString() : null; + int? year = null; + if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4 && int.TryParse(dateStr.Substring(0, 4), out var yr)) + { + year = yr; + } + + if (!string.IsNullOrEmpty(partId)) + { + var posterPath = part.TryGetProperty("poster_path", out var pProp) ? pProp.GetString() : null; + items.Add(new CustomRowItem + { + Id = long.Parse(partId), + Name = title, + Type = "Movie", + ProductionYear = year, + Rank = rank++, + ProviderIds = new CustomRowItemProviderIds + { + Tmdb = partId + }, + PosterUrl = posterPath + }); + } + } + } + } + else + { + if (root.TryGetProperty("items", out var itemsProp) && itemsProp.ValueKind == JsonValueKind.Array) + { + foreach (var item in itemsProp.EnumerateArray()) + { + var itemId = item.TryGetProperty("id", out var idProp) ? idProp.GetInt64().ToString() : null; + var title = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + if (string.IsNullOrEmpty(title) && item.TryGetProperty("name", out var nameProp)) + { + title = nameProp.GetString(); + } + title ??= "Unknown"; + + var dateStr = item.TryGetProperty("release_date", out var rdProp) ? rdProp.GetString() : null; + if (string.IsNullOrEmpty(dateStr) && item.TryGetProperty("first_air_date", out var fadProp)) + { + dateStr = fadProp.GetString(); + } + + int? year = null; + if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4 && int.TryParse(dateStr.Substring(0, 4), out var yr)) + { + year = yr; + } + + var mediaType = item.TryGetProperty("media_type", out var mtProp) ? mtProp.GetString() : null; + var finalType = mediaType == "tv" ? "Series" : "Movie"; + + if (!string.IsNullOrEmpty(itemId)) + { + var posterPath = item.TryGetProperty("poster_path", out var pProp) ? pProp.GetString() : null; + items.Add(new CustomRowItem + { + Id = long.Parse(itemId), + Name = title, + Type = finalType, + ProductionYear = year, + Rank = rank++, + ProviderIds = new CustomRowItemProviderIds + { + Tmdb = itemId + }, + PosterUrl = posterPath + }); + } + } + } + } + + return items; + } + + private async Task> FetchLetterboxd( + string type, + Dictionary paramsMap, + Guid userId, + CancellationToken cancellationToken) + { + paramsMap.TryGetValue("user", out var username); + paramsMap.TryGetValue("name", out var listname); + + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("Letterboxd requires user parameter."); + } + + var url = type switch + { + "watchlist" => $"https://letterboxd.com/{Uri.EscapeDataString(username)}/watchlist/rss/", + "films" => $"https://letterboxd.com/{Uri.EscapeDataString(username)}/films/rss/", + _ => $"https://letterboxd.com/{Uri.EscapeDataString(username)}/list/{Uri.EscapeDataString(listname ?? "")}/rss/" + }; + + var client = CreateClient(); + using var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Letterboxd RSS feed returned status {(int)response.StatusCode}"); + } + + var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var document = XDocument.Parse(xmlContent); + + var items = new List(); + var rssItems = document.Descendants("item"); + + EnsureSlugsLoaded(); + + // 1. Parse Letterboxd details from XML feed + var parsedFeedItems = new List(); + foreach (var rssItem in rssItems) + { + var title = rssItem.Element("title")?.Value ?? "Unknown"; + var link = rssItem.Element("link")?.Value ?? ""; + + var filmTitle = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmTitle")?.Value ?? title; + var filmYearStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmYear")?.Value; + var memberRatingStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "memberRating")?.Value; + + int? year = null; + if (int.TryParse(filmYearStr, out var y)) year = y; + + double? rating = null; + if (double.TryParse(memberRatingStr, out var r)) rating = r; + + var slugMatch = Regex.Match(link, @"film/([^/]+)/?"); + if (slugMatch.Success) + { + var slug = slugMatch.Groups[1].Value; + parsedFeedItems.Add(new LetterboxdFeedItem + { + Title = filmTitle, + Year = year, + Rating = rating, + Slug = slug + }); + } + } + + // 2. Resolve TMDB IDs for each slug + var slugsToFetch = new List(); + foreach (var pItem in parsedFeedItems) + { + if (_letterboxdSlugMap!.TryGetValue(pItem.Slug, out var tmdbId)) + { + pItem.TmdbId = tmdbId; + } + else + { + slugsToFetch.Add(pItem.Slug); + } + } + + // Fetch unresolved slugs sequentially with a minor delay to avoid rate limit + if (slugsToFetch.Count > 0) + { + foreach (var slug in slugsToFetch) + { + try + { + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + var filmUrl = $"https://letterboxd.com/film/{slug}/"; + using var filmResp = await client.GetAsync(filmUrl, cancellationToken).ConfigureAwait(false); + if (filmResp.IsSuccessStatusCode) + { + var filmHtml = await filmResp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var tmdbMatch = Regex.Match(filmHtml, @"data-tmdb-id=""(\d+)"""); + if (tmdbMatch.Success) + { + var resolvedId = tmdbMatch.Groups[1].Value; + _letterboxdSlugMap![slug] = resolvedId; + + // Map to items in this run + foreach (var pItem in parsedFeedItems.Where(i => i.Slug == slug)) + { + pItem.TmdbId = resolvedId; + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve TMDb ID for Letterboxd slug: {Slug}", slug); + } + } + + await FlushSlugsAsync().ConfigureAwait(false); + } + + // 2.5 Fetch poster paths from TMDb API in parallel if TMDB key is configured + var resolvedProfile = await _settingsService.GetResolvedProfileAsync(userId, "global"); + var tmdbKey = resolvedProfile?.TmdbApiKey; + if (string.IsNullOrWhiteSpace(tmdbKey)) + { + tmdbKey = MoonfinPlugin.Instance?.Configuration?.TmdbApiKey; + } + + if (!string.IsNullOrWhiteSpace(tmdbKey)) + { + var movieTasks = parsedFeedItems.Where(i => !string.IsNullOrEmpty(i.TmdbId)).Select(async pItem => + { + try + { + var tmdbUrl = $"https://api.themoviedb.org/3/movie/{pItem.TmdbId}"; + using var req = new HttpRequestMessage(HttpMethod.Get, tmdbUrl); + ApplyTmdbAuth(req, tmdbKey); + using var resp = await client.SendAsync(req, cancellationToken).ConfigureAwait(false); + if (resp.IsSuccessStatusCode) + { + var detailsJson = await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var doc = JsonDocument.Parse(detailsJson); + var detailsRoot = doc.RootElement; + if (detailsRoot.TryGetProperty("poster_path", out var pProp) && pProp.ValueKind == JsonValueKind.String) + { + pItem.PosterUrl = pProp.GetString(); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch TMDb details for movie ID: {Id}", pItem.TmdbId); + } + }); + await Task.WhenAll(movieTasks).ConfigureAwait(false); + } + + // 3. For successfully resolved TMDB IDs, build CustomRowItem lists + int rank = 1; + foreach (var pItem in parsedFeedItems) + { + if (!string.IsNullOrEmpty(pItem.TmdbId)) + { + var stars = pItem.Rating.HasValue ? FormatRatingToStars(pItem.Rating.Value) : null; + items.Add(new CustomRowItem + { + Id = long.Parse(pItem.TmdbId), + Name = pItem.Title, + Type = "Movie", // Letterboxd is strictly movies + ProductionYear = pItem.Year, + Rank = rank++, + ProviderIds = new CustomRowItemProviderIds + { + Tmdb = pItem.TmdbId + }, + UserRating = stars, + PosterUrl = pItem.PosterUrl + }); + } + } + + return items; + } + + private static string FormatRatingToStars(double rating) + { + int fullStars = (int)Math.Floor(rating); + bool halfStar = (rating - fullStars) >= 0.25; + var stars = new string('★', fullStars); + if (halfStar) stars += "½"; + return stars; + } + + private void EnsureSlugsLoaded() + { + if (_letterboxdSlugMap != null) return; + + _slugFileLock.Wait(); + try + { + if (_letterboxdSlugMap != null) return; + + if (System.IO.File.Exists(_letterboxdSlugsFilePath)) + { + try + { + var json = System.IO.File.ReadAllText(_letterboxdSlugsFilePath); + var dict = JsonSerializer.Deserialize>(json); + _letterboxdSlugMap = dict != null + ? new ConcurrentDictionary(dict, StringComparer.OrdinalIgnoreCase) + : new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + catch + { + _letterboxdSlugMap = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + } + else + { + _letterboxdSlugMap = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + } + finally + { + _slugFileLock.Release(); + } + } + + private async Task FlushSlugsAsync() + { + if (_letterboxdSlugMap == null) return; + + await _slugFileLock.WaitAsync().ConfigureAwait(false); + try + { + var json = JsonSerializer.Serialize(_letterboxdSlugMap); + await System.IO.File.WriteAllTextAsync(_letterboxdSlugsFilePath, json).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to flush Letterboxd slugs map to disk"); + } + finally + { + _slugFileLock.Release(); + } + } + + private HttpClient CreateClient() + { + var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + return client; + } + + private static void ApplyTmdbAuth(HttpRequestMessage request, string apiKey) + { + if (apiKey.StartsWith("eyJ", StringComparison.Ordinal)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + } + else + { + var uriBuilder = new UriBuilder(request.RequestUri!); + var query = System.Web.HttpUtility.ParseQueryString(uriBuilder.Query); + query["api_key"] = apiKey; + uriBuilder.Query = query.ToString(); + request.RequestUri = uriBuilder.Uri; + } + } + + private static string GetStringSha256Hash(string text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + using var sha = System.Security.Cryptography.SHA256.Create(); + var textData = System.Text.Encoding.UTF8.GetBytes(text); + var hashData = sha.ComputeHash(textData); + return Convert.ToHexString(hashData); + } +} + +internal class LetterboxdFeedItem +{ + public string Title { get; set; } = string.Empty; + public int? Year { get; set; } + public double? Rating { get; set; } + public string Slug { get; set; } = string.Empty; + public string? TmdbId { get; set; } + public string? PosterUrl { get; set; } +} + +public class CustomRowResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("items")] + public List Items { get; set; } = new(); +} diff --git a/backend/PluginServiceRegistrator.cs b/backend/PluginServiceRegistrator.cs index c4c9a1e..5561f4c 100644 --- a/backend/PluginServiceRegistrator.cs +++ b/backend/PluginServiceRegistrator.cs @@ -19,6 +19,7 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddHttpClient(); // Auto-register file transformations on plugin load (no manual task needed) diff --git a/backend/Services/CustomRowCacheService.cs b/backend/Services/CustomRowCacheService.cs new file mode 100644 index 0000000..8afe87d --- /dev/null +++ b/backend/Services/CustomRowCacheService.cs @@ -0,0 +1,170 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Moonfin.Server.Services; + +/// +/// Persistent file-backed cache for fully custom home rows. +/// Keyed by source:type:paramHash. +/// +public class CustomRowCacheService +{ + private readonly string _cacheFilePath; + private readonly ILogger _logger; + private readonly SemaphoreSlim _fileLock = new(1, 1); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private ConcurrentDictionary? _cache; + + public CustomRowCacheService(ILogger logger) + { + _logger = logger; + var dataPath = MoonfinPlugin.Instance?.DataFolderPath + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Jellyfin", "plugins", "Moonfin"); + + if (!Directory.Exists(dataPath)) + { + Directory.CreateDirectory(dataPath); + } + + _cacheFilePath = Path.Combine(dataPath, "custom_rows_cache.json"); + } + + public List? TryGet(string cacheKey, TimeSpan maxAge) + { + var cache = EnsureLoaded(); + if (cache.TryGetValue(cacheKey, out var entry) && + DateTimeOffset.UtcNow - entry.CachedAt < maxAge) + { + return entry.Items; + } + return null; + } + + public void Set(string cacheKey, List items) + { + var cache = EnsureLoaded(); + cache[cacheKey] = new CustomRowCacheEntry + { + Items = items, + CachedAt = DateTimeOffset.UtcNow + }; + } + + public async Task FlushAsync() + { + var cache = _cache; + if (cache == null) return; + + await _fileLock.WaitAsync().ConfigureAwait(false); + try + { + await using var stream = File.Create(_cacheFilePath); + await JsonSerializer.SerializeAsync(stream, cache, JsonOptions).ConfigureAwait(false); + _logger.LogDebug("Custom rows cache flushed to disk ({Count} entries)", cache.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to flush Custom rows cache to disk"); + } + finally + { + _fileLock.Release(); + } + } + + private ConcurrentDictionary EnsureLoaded() + { + if (_cache != null) return _cache; + + _fileLock.Wait(); + try + { + if (_cache != null) return _cache; + + if (File.Exists(_cacheFilePath)) + { + try + { + using var stream = File.OpenRead(_cacheFilePath); + var loaded = JsonSerializer.Deserialize>(stream, JsonOptions); + _cache = loaded != null + ? new ConcurrentDictionary(loaded, StringComparer.OrdinalIgnoreCase) + : new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _logger.LogInformation("Custom rows cache loaded from disk ({Count} entries)", _cache.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load Custom rows cache from disk, starting fresh"); + _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + } + else + { + _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + } + finally + { + _fileLock.Release(); + } + + return _cache; + } +} + +public class CustomRowCacheEntry +{ + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("cachedAt")] + public DateTimeOffset CachedAt { get; set; } +} + +public class CustomRowItem +{ + [JsonPropertyName("id")] + public long? Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("productionYear")] + public int? ProductionYear { get; set; } + + [JsonPropertyName("rank")] + public int? Rank { get; set; } + + [JsonPropertyName("providerIds")] + public CustomRowItemProviderIds ProviderIds { get; set; } = new(); + + [JsonPropertyName("userRating")] + public string? UserRating { get; set; } + + [JsonPropertyName("posterUrl")] + public string? PosterUrl { get; set; } +} + +public class CustomRowItemProviderIds +{ + [JsonPropertyName("Imdb")] + public string? Imdb { get; set; } + + [JsonPropertyName("Tmdb")] + public string? Tmdb { get; set; } + + [JsonPropertyName("Tvdb")] + public string? Tvdb { get; set; } +} From 55ec467c0acc0746ec4063d6ec729691cf737e2d Mon Sep 17 00:00:00 2001 From: mattsigal Date: Sun, 28 Jun 2026 14:34:55 -0700 Subject: [PATCH 02/10] feat(plugin): parallelize scraper, lowercase username, TMDB MDBList integration --- backend/Api/CustomRowController.cs | 356 ++++++++++++++++++--- backend/Services/CustomRowCacheService.cs | 3 + manifest.json | 364 ++++++++++++---------- 3 files changed, 510 insertions(+), 213 deletions(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 292adc1..2c55a0c 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -101,6 +101,9 @@ public async Task> GetCustomRowItems( case "tmdb": items = await FetchTmdb(type, parsedParams, userId.Value, cancellationToken); break; + case "tmdb_chart": + items = await FetchTmdbChart(type, userId.Value, cancellationToken); + break; case "letterboxd": items = await FetchLetterboxd(type, parsedParams, userId.Value, cancellationToken); break; @@ -245,6 +248,58 @@ private async Task> FetchMdbList( }); } + // Fetch posters and backdrops from TMDb API in parallel to support high-res and backdrops + var resolvedProfile = await _settingsService.GetResolvedProfileAsync(userId, "global"); + var tmdbKey = resolvedProfile?.TmdbApiKey; + if (string.IsNullOrWhiteSpace(tmdbKey)) + { + tmdbKey = MoonfinPlugin.Instance?.Configuration?.TmdbApiKey; + } + + if (!string.IsNullOrWhiteSpace(tmdbKey)) + { + using var tmdbSemaphore = new SemaphoreSlim(15, 15); + var movieTasks = items.Where(i => i.Id.HasValue).Select(async rowItem => + { + await tmdbSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var isShow = rowItem.Type == "Series"; + var tmdbUrl = isShow + ? $"https://api.themoviedb.org/3/tv/{rowItem.Id}" + : $"https://api.themoviedb.org/3/movie/{rowItem.Id}"; + + using var req = new HttpRequestMessage(HttpMethod.Get, tmdbUrl); + ApplyTmdbAuth(req, tmdbKey); + using var resp = await client.SendAsync(req, cancellationToken).ConfigureAwait(false); + if (resp.IsSuccessStatusCode) + { + var detailsJson = await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var doc = JsonDocument.Parse(detailsJson); + var detailsRoot = doc.RootElement; + + if (detailsRoot.TryGetProperty("poster_path", out var pProp) && pProp.ValueKind == JsonValueKind.String) + { + rowItem.PosterUrl = pProp.GetString(); + } + if (detailsRoot.TryGetProperty("backdrop_path", out var bProp) && bProp.ValueKind == JsonValueKind.String) + { + rowItem.BackdropUrl = bProp.GetString(); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch TMDb details for MDBList item ID: {Id}", rowItem.Id); + } + finally + { + tmdbSemaphore.Release(); + } + }); + await Task.WhenAll(movieTasks).ConfigureAwait(false); + } + return items; } @@ -312,6 +367,7 @@ private async Task> FetchTmdb( if (!string.IsNullOrEmpty(partId)) { var posterPath = part.TryGetProperty("poster_path", out var pProp) ? pProp.GetString() : null; + var backdropPath = part.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { Id = long.Parse(partId), @@ -323,7 +379,8 @@ private async Task> FetchTmdb( { Tmdb = partId }, - PosterUrl = posterPath + PosterUrl = posterPath, + BackdropUrl = backdropPath }); } } @@ -361,6 +418,7 @@ private async Task> FetchTmdb( if (!string.IsNullOrEmpty(itemId)) { var posterPath = item.TryGetProperty("poster_path", out var pProp) ? pProp.GetString() : null; + var backdropPath = item.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { Id = long.Parse(itemId), @@ -372,7 +430,8 @@ private async Task> FetchTmdb( { Tmdb = itemId }, - PosterUrl = posterPath + PosterUrl = posterPath, + BackdropUrl = backdropPath }); } } @@ -382,6 +441,98 @@ private async Task> FetchTmdb( return items; } + private async Task> FetchTmdbChart( + string type, + Guid userId, + CancellationToken cancellationToken) + { + var resolved = await _settingsService.GetResolvedProfileAsync(userId, "global"); + var apiKey = resolved?.TmdbApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + apiKey = MoonfinPlugin.Instance?.Configuration?.TmdbApiKey; + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("TMDB API Key is not configured."); + } + + // type is the API path, e.g. movie/popular, trending/movie/day, etc. + var url = $"https://api.themoviedb.org/3/{type}"; + var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + ApplyTmdbAuth(request, apiKey); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"TMDB API returned status {(int)response.StatusCode} for chart {type}"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + var items = new List(); + int rank = 1; + + if (root.TryGetProperty("results", out var resultsProp) && resultsProp.ValueKind == JsonValueKind.Array) + { + foreach (var item in resultsProp.EnumerateArray()) + { + var itemId = item.TryGetProperty("id", out var idProp) ? idProp.GetInt64().ToString() : null; + var title = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + if (string.IsNullOrEmpty(title) && item.TryGetProperty("name", out var nameProp)) + { + title = nameProp.GetString(); + } + title ??= "Unknown"; + + var dateStr = item.TryGetProperty("release_date", out var rdProp) ? rdProp.GetString() : null; + if (string.IsNullOrEmpty(dateStr) && item.TryGetProperty("first_air_date", out var fadProp)) + { + dateStr = fadProp.GetString(); + } + + int? year = null; + if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4 && int.TryParse(dateStr.Substring(0, 4), out var yr)) + { + year = yr; + } + + var mediaType = item.TryGetProperty("media_type", out var mtProp) ? mtProp.GetString() : null; + if (string.IsNullOrEmpty(mediaType)) + { + mediaType = type.Contains("tv") ? "tv" : "movie"; + } + var finalType = mediaType == "tv" ? "Series" : "Movie"; + + if (!string.IsNullOrEmpty(itemId)) + { + var posterPath = item.TryGetProperty("poster_path", out var pProp) ? pProp.GetString() : null; + var backdropPath = item.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; + items.Add(new CustomRowItem + { + Id = long.Parse(itemId), + Name = title, + Type = finalType, + ProductionYear = year, + Rank = rank++, + ProviderIds = new CustomRowItemProviderIds + { + Tmdb = itemId + }, + PosterUrl = posterPath, + BackdropUrl = backdropPath + }); + } + } + } + + return items; + } + private async Task> FetchLetterboxd( string type, Dictionary paramsMap, @@ -390,62 +541,137 @@ private async Task> FetchLetterboxd( { paramsMap.TryGetValue("user", out var username); paramsMap.TryGetValue("name", out var listname); + paramsMap.TryGetValue("url", out var fullUrl); - if (string.IsNullOrWhiteSpace(username)) + if (type == "user_list") { - throw new ArgumentException("Letterboxd requires user parameter."); + if (string.IsNullOrWhiteSpace(fullUrl)) + { + throw new ArgumentException("Letterboxd URL lists require a URL."); + } + + // Normalise URL / path (e.g. /official/list/letterboxds-top-500-films/ or https://letterboxd.com/...) + var cleanedUrl = fullUrl.Replace("https://", "").Replace("http://", "").Replace("www.", "").Replace("letterboxd.com/", ""); + if (cleanedUrl.StartsWith('/')) cleanedUrl = cleanedUrl.Substring(1); + + var parts = cleanedUrl.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3 && parts[1] == "list") + { + username = parts[0]; + listname = parts[2]; + } + else if (parts.Length >= 2) + { + username = parts[0]; + listname = parts[1]; + } + else + { + throw new ArgumentException("Invalid Letterboxd list URL or path format."); + } } - var url = type switch + if (username != null) { - "watchlist" => $"https://letterboxd.com/{Uri.EscapeDataString(username)}/watchlist/rss/", - "films" => $"https://letterboxd.com/{Uri.EscapeDataString(username)}/films/rss/", - _ => $"https://letterboxd.com/{Uri.EscapeDataString(username)}/list/{Uri.EscapeDataString(listname ?? "")}/rss/" - }; + username = username.ToLowerInvariant().Trim(); + } - var client = CreateClient(); - using var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + if (string.IsNullOrWhiteSpace(username) && type != "user_list") { - throw new Exception($"Letterboxd RSS feed returned status {(int)response.StatusCode}"); + throw new ArgumentException("Letterboxd requires user parameter."); } - var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var document = XDocument.Parse(xmlContent); + var isHtmlCrawled = type == "user_list" || type == "watchlist" || type == "films"; + var baseUrl = type switch + { + "watchlist" => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/watchlist/", + "films" => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/films/", + "user_list" => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/list/{Uri.EscapeDataString(listname ?? "")}/", + _ => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/rss/" + }; + var client = CreateClient(); var items = new List(); - var rssItems = document.Descendants("item"); - EnsureSlugsLoaded(); - // 1. Parse Letterboxd details from XML feed var parsedFeedItems = new List(); - foreach (var rssItem in rssItems) + + if (isHtmlCrawled) { - var title = rssItem.Element("title")?.Value ?? "Unknown"; - var link = rssItem.Element("link")?.Value ?? ""; - - var filmTitle = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmTitle")?.Value ?? title; - var filmYearStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmYear")?.Value; - var memberRatingStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "memberRating")?.Value; + for (int page = 1; page <= 5; page++) + { + var pageUrl = page == 1 + ? baseUrl + : $"{baseUrl}page/{page}/"; - int? year = null; - if (int.TryParse(filmYearStr, out var y)) year = y; + using var pageResp = await client.GetAsync(pageUrl, cancellationToken).ConfigureAwait(false); + if (!pageResp.IsSuccessStatusCode) + { + break; + } + + var htmlContent = await pageResp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var matches = Regex.Matches(htmlContent, @"data-target-link=""/film/([^/]+)/"""); + if (matches.Count == 0) + { + break; + } - double? rating = null; - if (double.TryParse(memberRatingStr, out var r)) rating = r; + foreach (Match match in matches) + { + if (match.Success) + { + var slug = match.Groups[1].Value; + if (!parsedFeedItems.Any(i => i.Slug == slug)) + { + parsedFeedItems.Add(new LetterboxdFeedItem + { + Title = slug, + Slug = slug + }); + } + } + } + } + } + else + { + using var response = await client.GetAsync(baseUrl, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Letterboxd returned status {(int)response.StatusCode} for {baseUrl}"); + } + var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var document = XDocument.Parse(xmlContent); + var rssItems = document.Descendants("item"); - var slugMatch = Regex.Match(link, @"film/([^/]+)/?"); - if (slugMatch.Success) + foreach (var rssItem in rssItems) { - var slug = slugMatch.Groups[1].Value; - parsedFeedItems.Add(new LetterboxdFeedItem + var title = rssItem.Element("title")?.Value ?? "Unknown"; + var link = rssItem.Element("link")?.Value ?? ""; + + var filmTitle = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmTitle")?.Value ?? title; + var filmYearStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmYear")?.Value; + var memberRatingStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "memberRating")?.Value; + + int? year = null; + if (int.TryParse(filmYearStr, out var y)) year = y; + + double? rating = null; + if (double.TryParse(memberRatingStr, out var r)) rating = r; + + var slugMatch = Regex.Match(link, @"film/([^/]+)/?"); + if (slugMatch.Success) { - Title = filmTitle, - Year = year, - Rating = rating, - Slug = slug - }); + var slug = slugMatch.Groups[1].Value; + parsedFeedItems.Add(new LetterboxdFeedItem + { + Title = filmTitle, + Year = year, + Rating = rating, + Slug = slug + }); + } } } @@ -463,14 +689,19 @@ private async Task> FetchLetterboxd( } } - // Fetch unresolved slugs sequentially with a minor delay to avoid rate limit + // Fetch unresolved slugs in parallel (max 10 concurrent requests) to avoid timeouts if (slugsToFetch.Count > 0) { - foreach (var slug in slugsToFetch) + using var slugSemaphore = new SemaphoreSlim(10, 10); + var tasks = slugsToFetch.Select(async slug => { + await slugSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await Task.Delay(150, cancellationToken).ConfigureAwait(false); + // Stagger request startup slightly to prevent instant bursts + var delayMs = new Random().Next(30, 250); + await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); + var filmUrl = $"https://letterboxd.com/film/{slug}/"; using var filmResp = await client.GetAsync(filmUrl, cancellationToken).ConfigureAwait(false); if (filmResp.IsSuccessStatusCode) @@ -482,10 +713,12 @@ private async Task> FetchLetterboxd( var resolvedId = tmdbMatch.Groups[1].Value; _letterboxdSlugMap![slug] = resolvedId; - // Map to items in this run - foreach (var pItem in parsedFeedItems.Where(i => i.Slug == slug)) + lock (parsedFeedItems) { - pItem.TmdbId = resolvedId; + foreach (var pItem in parsedFeedItems.Where(i => i.Slug == slug)) + { + pItem.TmdbId = resolvedId; + } } } } @@ -494,12 +727,17 @@ private async Task> FetchLetterboxd( { _logger.LogWarning(ex, "Failed to resolve TMDb ID for Letterboxd slug: {Slug}", slug); } - } + finally + { + slugSemaphore.Release(); + } + }); + await Task.WhenAll(tasks).ConfigureAwait(false); await FlushSlugsAsync().ConfigureAwait(false); } - // 2.5 Fetch poster paths from TMDb API in parallel if TMDB key is configured + // 2.5 Fetch full details/posters from TMDb API in parallel (max 15 concurrent requests) var resolvedProfile = await _settingsService.GetResolvedProfileAsync(userId, "global"); var tmdbKey = resolvedProfile?.TmdbApiKey; if (string.IsNullOrWhiteSpace(tmdbKey)) @@ -509,8 +747,10 @@ private async Task> FetchLetterboxd( if (!string.IsNullOrWhiteSpace(tmdbKey)) { + using var tmdbSemaphore = new SemaphoreSlim(15, 15); var movieTasks = parsedFeedItems.Where(i => !string.IsNullOrEmpty(i.TmdbId)).Select(async pItem => { + await tmdbSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { var tmdbUrl = $"https://api.themoviedb.org/3/movie/{pItem.TmdbId}"; @@ -522,16 +762,36 @@ private async Task> FetchLetterboxd( var detailsJson = await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); using var doc = JsonDocument.Parse(detailsJson); var detailsRoot = doc.RootElement; + if (detailsRoot.TryGetProperty("title", out var titleProp) && titleProp.ValueKind == JsonValueKind.String) + { + pItem.Title = titleProp.GetString() ?? pItem.Title; + } + if (detailsRoot.TryGetProperty("release_date", out var rdProp) && rdProp.ValueKind == JsonValueKind.String) + { + var dateStr = rdProp.GetString(); + if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4 && int.TryParse(dateStr.Substring(0, 4), out var yr)) + { + pItem.Year = yr; + } + } if (detailsRoot.TryGetProperty("poster_path", out var pProp) && pProp.ValueKind == JsonValueKind.String) { pItem.PosterUrl = pProp.GetString(); } + if (detailsRoot.TryGetProperty("backdrop_path", out var bProp) && bProp.ValueKind == JsonValueKind.String) + { + pItem.BackdropUrl = bProp.GetString(); + } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch TMDb details for movie ID: {Id}", pItem.TmdbId); } + finally + { + tmdbSemaphore.Release(); + } }); await Task.WhenAll(movieTasks).ConfigureAwait(false); } @@ -555,7 +815,8 @@ private async Task> FetchLetterboxd( Tmdb = pItem.TmdbId }, UserRating = stars, - PosterUrl = pItem.PosterUrl + PosterUrl = pItem.PosterUrl, + BackdropUrl = pItem.BackdropUrl }); } } @@ -669,6 +930,7 @@ internal class LetterboxdFeedItem public string Slug { get; set; } = string.Empty; public string? TmdbId { get; set; } public string? PosterUrl { get; set; } + public string? BackdropUrl { get; set; } } public class CustomRowResponse diff --git a/backend/Services/CustomRowCacheService.cs b/backend/Services/CustomRowCacheService.cs index 8afe87d..f264b14 100644 --- a/backend/Services/CustomRowCacheService.cs +++ b/backend/Services/CustomRowCacheService.cs @@ -155,6 +155,9 @@ public class CustomRowItem [JsonPropertyName("posterUrl")] public string? PosterUrl { get; set; } + + [JsonPropertyName("backdropUrl")] + public string? BackdropUrl { get; set; } } public class CustomRowItemProviderIds diff --git a/manifest.json b/manifest.json index 8caeef8..b0285c4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,166 +1,198 @@ -[ - { - "guid": "8c5d0e91-4f2a-4b6d-9e3f-1a7c8d9e0f2b", - "name": "Moonbase", - "overview": "Moonfin server plugin for Jellyfin with web app hosting, settings sync, theming, and Seerr integrations", - "description": "Moonbase is the Moonfin server plugin for Jellyfin, powering settings sync, the Moonfin Web app at /Moonfin/Web/, a built-in theme editor with custom theme APIs, media bar and ratings integrations, and Seerr proxy/SSO with admin defaults and broadcasts across Moonfin clients.", - "owner": "RadicalMuffinMan", - "category": "General", - "imageUrl": "https://raw.githubusercontent.com/Moonfin-Client/Plugin/master/backend/assets/logo.png", - "versions": [ - { - "version": "1.9.1.0", - "changelog": "Features:\n\n- The web theme editor gained a random theme generator that builds a complete, coherent palette from a random base and accent hue, while keeping your theme id, name, and description. The theme id validation message was also clarified.\n\nBug Fixes:\n\n- Media Bar trailers on web were reworked so YouTube trailer playback and advancing to the next slide now work correctly.\n- Web playback sessions are now reported as stopped when you close or navigate away from the browser tab, using a page exit beacon, so they no longer linger as active on the server.\n- The sidebar now follows desktop behavior in desktop browsers, instead of using the mobile layout.\n- Preferences are now parsed and type coerced more robustly for web compatibility, fixing settings that could fail to load or save when stored as browser strings.\n- The refresh rate switching and audio output mode settings are now hidden on the web build, where they do not apply.\n- ASS and PGS subtitles now render correctly on web, with the necessary fonts bundled.", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.9.1/Moonfin.Server-1.9.1.0.zip", - "checksum": "6B66E4CC9F15B423BC93FA913A11F87F", - "timestamp": "2026-06-09T03:18:42", - "dependencies": [] - }, - { - "version": "1.9.0.0", - "changelog": "## Features\n\n- Refreshed the Moonfin plugin, now called Moonbase, experience with the Moonfin Flutter web app, now served from `/Moonfin/Web/`.\n- Replaced the older JavaScript overlay frontend with a proper web client bundled from the Flutter web project at the Moonfin-Core repo and theme assets for a smoother setup.\n- Added a built-in theme editor at `/Moonfin/Web/theme/`, including admin-managed custom theme uploads and validation.\n- Added discovery endpoints for plugin web startup: `/Moonfin/Discovery` and `/Moonfin/Discovery/discover`.\n- Added custom theme APIs and services for listing, retrieving, uploading or replacing, deleting, and validating themes.\n- Improved settings sync and admin tools with better global/profile behavior and admin broadcast support.", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.9.0/Moonfin.Server-1.9.0.0.zip", - "checksum": "BCFEC3007BCCA81F535594FA8332FD3F", - "timestamp": "2026-06-01T02:47:41", - "dependencies": [] - }, - { - "version": "1.8.2.0", - "changelog": "## Features\n\n- Added Media Bar Enhanced support in desktop media bar settings. (#98)\n- Clicking a row item now navigates to the correct library, and playback from the Continue Watching row works properly again. (#60)\n\n## Bug Fixes\n\n- Details page buttons were too large on some screens — they've been resized to something more reasonable. (#100)\n- Clicking items from third-party home screen section plugins no longer incorrectly triggers Moonfin's custom detail page. (#96)\n- Admin default settings now correctly propagate to existing users when changed. (#101)\n- The Jellyseerr discover page was crashing to an error screen because the proxy was wrapping responses in an unexpected JSON envelope. That's fixed now. (#108)\n- The global profile now saves reliably, the media bar state persists correctly across Moonfin and Paradox themes, and users no longer see genres or library sections they don't have access to. (#87)", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.8.2/Moonfin.Server-1.8.2.0.zip", - "checksum": "C559FB46DFE70DD27F151FB928D2F604", - "timestamp": "2026-04-19T20:07:23", - "dependencies": [] - }, - { - "version": "1.8.1.0", - "changelog": "## Bug Fixes\n\n- Fixed Moonfin UI not loading on Jellyfin instances deployed under a reverse proxy sub-path\n- Fixed Moonfin login detection using private Jellyfin API internals that could return falsy values depending on Jellyfin version, causing the plugin to never initialize\n- Fixed Jellyseerr proxy (cookies, HTML/CSS rewrites, injected script) using hardcoded root-relative paths that broke under reverse proxy sub-path deployments\n- Fixed Letterboxd ratings showing doubled values for entries cached prior to v1.7.0", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.8.1/Moonfin.Server-1.8.1.0.zip", - "checksum": "7B8C49B2D56C554818D402CD7D8DD583", - "timestamp": "2026-04-07T18:09:53", - "dependencies": [] - }, - { - "version": "1.8.0.0", - "changelog": "## Features\n\n- Added compatibility with @IAmParadox27 's [media bar](https://github.com/IAmParadox27/jellyfin-plugin-media-bar/) and [Home Screen Sections](https://github.com/IAmParadox27/jellyfin-plugin-home-sections) rows\n- Added support for @ranaldsgift 's [KefinTweaks](https://github.com/ranaldsgift/KefinTweaks) rows\n> [!NOTE] \n> The rows are not yet implemented in the other clients. This is a manual method that will take time but the framework for implementation is done. The media bar compatibility is for web UI only under the desktop profile\n\n- Media bar went full screen like other clients\n- Added sync mode clarity dialog box\n- Added trailer overlay in details screen\n- Added details blur/opacity controls and new endpoints so it syncs with clients\n- Added special features, chapters, and collections in details\n- Added back button and fixed details tint behavior\n- Added favorite button for people details\n- Added favorite/watched indicators in details and library\n- Updated libraries UI\n- Updated collections to display contents in release order\n- Updated Moonfin plugin logo\n\n## Bug Fixes\n\n- Fixed details tint behavior that was causing the original jellyfin ui's backdrop image to look black\n- Fixed header bar glitch that was causing the library title to scroll with users in the library\n- Fixed plugin and UI not applying correctly (#60, #66, #76)\n- Fixed removed media bar items that did not have a backdrop and removed items that users did not have access to.\n- Fixed HDR badge truthy logic and expanded HDR range handling\n- Fixed trailer button forwards to home page\n- Fixed special features not showing with new details page\n- Fixed Seerr invalid CSRF token (#53, #63)\n- Fixed Seerr icon not showing on toolbar\n- Fixed Seerr integration slowness\n- Fixed /Moonfin/Settings returns 404 via reverse proxy", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.8.0/Moonfin.Server-1.8.0.0.zip", - "checksum": "BF2DA2868F49C577F14F2D61354F6281", - "timestamp": "2026-04-01T16:05:10", - "dependencies": [] - }, - { - "version": "1.7.1.0", - "changelog": "New Features:\n- Media Bar Genre Exclusions - exclude specific genres from the media bar so items from those genres won't appear, configurable per-user and as admin defaults\n- GET /Moonfin/Genres endpoint - returns all genre IDs and localized names for the genre picker\n\nBug Fixes:\n- Fixed Jellyseerr CSRF token errors causing failed authentication and proxy requests\n- Fixed Jellyseerr button not appearing when navbar/sidebar initializes after config is dispatched\n- Fixed media bar showing loading state indefinitely when no items are returned\n- Fixed media bar active class being added when there are no items to display\n- Fixed unnecessary Content-Type header on GET requests\n- Fixed duplicate event listeners on collection/library picker re-renders\n\nImprovements:\n- CSRF tokens are now fetched and sent for all Jellyseerr write operations with a fallback for IP-based URLs\n- Media bar loading class is now removed in error and empty states", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.7.1/Moonfin.Server-1.7.1.0.zip", - "checksum": "ECD1DE99E2A7CC34A4A7BD856848E073", - "timestamp": "2026-03-16T18:02:45", - "dependencies": [] - }, - { - "version": "1.7.0.0", - "changelog": "New Features:\n- Home Screen Row Ordering - drag and drop to reorder or hide home screen sections like Continue Watching, Next Up, and Latest Media\n- Media Bar Collections - choose specific collections or playlists to show in the media bar instead of pulling from all libraries\n- MDBList rating source labels can now be shown or hidden\n- Automatic settings change when switching between users on the same device\n\nNew Endpoint:\n- GET /Moonfin/MediaBar - returns media bar items based on user settings\n\nBug Fixes:\n- Fixed playback not working from the details page\n- Fixed media bar showing on pages other than the home screen\n- Fixed Seerr/Jellyseerr button not appearing after settings sync\n- Fixed details page interfering with media selection dialogs\n- Fixed incorrect Letterboxd rating values\n\nImprovements:\n- Reusable drag-and-drop sortable list component", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.7.0/Moonfin.Server-1.7.0.0.zip", - "checksum": "B698F944D017AD4675C16C222F8BB25D", - "timestamp": "2026-03-15T03:21:44", - "dependencies": [] - }, - { - "version": "1.6.0.0", - "changelog": "Bug Fixes:\n- Fixed Seerr/Jellyseerr proxy navigation - search, sidebar links, and in-app navigation no longer load indefinitely\n- Fixed user settings not saving correctly - refactored settings mapping for consistency\n- Fixed navbar background to use a gradient\n- Fixed admin config page not appearing in Jellyfin admin sidebar\n\nImprovements:\n- Media version/quality information now displayed on details overlay\n- Authentication now requires only a username for passwordless Jellyfin users\n- MDBList batch task refactored for better memory management with stream-based JSON I/O", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.6.0/Moonfin.Server-1.6.0.0.zip", - "checksum": "1764D016C791637BA0182F5662456B54", - "timestamp": "2026-03-14T12:47:13", - "dependencies": [] - }, - { - "version": "1.5.1.0", - "changelog": "Bug Fixes:\n- Fixed MDBList and TMDB user API keys not being read from v2 settings profiles (only admin keys worked)\n- Fixed user-selected MDBList rating sources being ignored after v1-to-v2 settings migration (only default 4 sources were returned)", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.5.1/Moonfin.Server-1.5.1.0.zip", - "checksum": "14CAC55434728A5E92CD2481E85DE152", - "timestamp": "2026-02-23T04:33:48", - "dependencies": [] - }, - { - "version": "1.5.0.0", - "changelog": "Features:\n- Device-specific settings profiles (global, desktop, mobile, TV) with inheritance and sync\n- Admin-configurable default user settings with per-user overrides\n- Sync toggle for enabling/disabling server settings synchronization\n\nImprovements:\n- Season posters fall back to series poster when unavailable (#15)\n- Lazy trailer fetching per displayed item instead of bulk RemoteTrailers query\n\nBug Fixes:\n- Fixed media bar auto-advance and trailer playback continuing after navigating away from home\n- Fixed media bar not stopping on admin pages and video player", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.5.0/Moonfin.Server-1.5.0.0.zip", - "checksum": "43D12A1CC3EBF5CDED1BC974BC925492", - "timestamp": "2026-02-22T18:33:27", - "dependencies": [] - }, - { - "version": "1.4.0.0", - "changelog": "Features:\n- Media bar trailer previews using YouTube IFrame API with SponsorBlock integration\n- Media bar redesign with logos, rating badges, and genre pills\n\nImprovements:\n- Seerr v3 support with auto-detection and direct iframe URL option\n- Server-wide TMDB API key with user notifications\n- Dynamic icon swapping for Jellyseerr/Seerr\n- Sidebar: settings moved under libraries with separator\n- Admin panel no longer blocked by plugin (whitelist-based route detection)\n- Sensitive data masked in console logs", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.4.0/Moonfin.Server-1.4.0.0.zip", - "checksum": "9E826973350BC5B2B56957F45D19EC56", - "timestamp": "2026-02-19T05:32:55", - "dependencies": [] - }, - { - "version": "1.3.1.0", - "changelog": "- Seerr v3 support with auto-detection (version-based)\n- Direct iframe URL option for Seerr behind reverse proxies\n- Server-wide TMDB API key with user notifications\n- Removed Rotten Tomatoes rating from details page\n- Added dynamic icon swapping for Jellyseerr/Seerr\n- Updated README with Seerr v3 reverse proxy documentation", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.3.1/Moonfin.Server-1.3.1.0.zip", - "checksum": "CAEF64ED665CD3C324F83B3EA490D281", - "timestamp": "2026-02-15T12:50:46", - "dependencies": [] - }, - { - "version": "1.3.0.0", - "changelog": "- Left sidebar navigation (configurable via navbarPosition)\n- Three-way settings merge with snapshot-based conflict resolution\n- MDBList server-side caching and batch pre-fetching\n\nImprovements:\n- PNG to SVG migration for all rating icons\n- Updated README and admin config page\n\nBug Fixes:\n- Fixed Jellyseerr session persistence (JSON deserialization)\n- Fixed Jellyseerr sessions on local IP addresses\n- Fixed Jellyseerr cookie rotation causing unexpected logouts\n- Fixed mobile media bar overlapping first library row", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.3.0/Moonfin.Server-1.3.0.0.zip", - "checksum": "39CC6BA712E02E2E5552A792CC1BF280", - "timestamp": "2026-02-14T22:00:16", - "dependencies": [] - }, - { - "version": "1.2.0.0", - "changelog": "- TMDB episode ratings with server-side caching\n- Drag & drop sortable rating sources\n- Jellyseerr dual login (Jellyfin + local accounts)\n- Edit Metadata/Images/Subtitles/Identify open as native dialogs\n- Create New Playlist from details page\n- Fixed homescreen context menu interception\n- Fixed subtitle picker triggering details overlay during playback", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.2.0/Moonfin.Server-1.2.0.0.zip", - "checksum": "FEBE25F651728978B685E8734E8B4953", - "timestamp": "2026-02-12T22:27:49", - "dependencies": [] - }, - { - "version": "1.1.1.0", - "changelog": "- Fixed Jellyseerr 404 when clicking links in iframe\n- Plugin now auto-registers file transformations on load (no manual task needed after updates)", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.1.1/Moonfin.Server-1.1.1.0.zip", - "checksum": "6F034F273541D5103C59B85346B275D4", - "timestamp": "2026-02-11T08:02:04", - "dependencies": [] - }, - { - "version": "1.1.0.0", - "changelog": "- Integrated File Transformation plugin for automatic web UI injection\n- Fixed bug where media bar did not load right away\n- Added server-wide MDBList API key support\n- Updated user settings and UI", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.1.0/Moonfin.Server-1.1.0.0.zip", - "checksum": "E635BA9B123DD5C8EB9AA32140FBA43D", - "timestamp": "2026-02-09T22:58:42", - "dependencies": [] - }, - { - "version": "1.0.0.0", - "changelog": "Initial release with core features: navbar, media bar, custom details screens, IMDBList integration, Jellyseerr integration, and settings sync.\n Hope you like it!", - "targetAbi": "10.10.0.0", - "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.0.0/Moonfin.Server-1.0.0.0.zip", - "checksum": "D96BC4F769AC38BA298258C0EF21F300", - "timestamp": "2026-02-11T07:59:01", - "dependencies": [] - } - ] - } -] +{ + "guid": "8c5d0e91-4f2a-4b6d-9e3f-1a7c8d9e0f2b", + "name": "Moonbase", + "overview": "Moonfin server plugin for Jellyfin with web app hosting, settings sync, theming, and Seerr integrations", + "description": "Moonbase is the Moonfin server plugin for Jellyfin, powering settings sync, the Moonfin Web app at /Moonfin/Web/, a built-in theme editor with custom theme APIs, media bar and ratings integrations, and Seerr proxy/SSO with admin defaults and broadcasts across Moonfin clients.", + "owner": "RadicalMuffinMan", + "category": "General", + "imageUrl": "https://raw.githubusercontent.com/Moonfin-Client/Plugin/master/backend/assets/logo.png", + "versions": [ + { + "version": "1.9.1.0", + "changelog": "Features:\n\n- The web theme editor gained a random theme generator that builds a complete, coherent palette from a random base and accent hue, while keeping your theme id, name, and description. The theme id validation message was also clarified.\n\nBug Fixes:\n\n- Media Bar trailers on web were reworked so YouTube trailer playback and advancing to the next slide now work correctly.\n- Web playback sessions are now reported as stopped when you close or navigate away from the browser tab, using a page exit beacon, so they no longer linger as active on the server.\n- The sidebar now follows desktop behavior in desktop browsers, instead of using the mobile layout.\n- Preferences are now parsed and type coerced more robustly for web compatibility, fixing settings that could fail to load or save when stored as browser strings.\n- The refresh rate switching and audio output mode settings are now hidden on the web build, where they do not apply.\n- ASS and PGS subtitles now render correctly on web, with the necessary fonts bundled.", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.9.1/Moonfin.Server-1.9.1.0.zip", + "checksum": "1D157CFC90A46C77050EED5D850D20D7", + "timestamp": "2026-06-28T20:46:34", + "dependencies": [ + + ] + }, + { + "version": "1.9.0.0", + "changelog": "## Features\n\n- Refreshed the Moonfin plugin, now called Moonbase, experience with the Moonfin Flutter web app, now served from `/Moonfin/Web/`.\n- Replaced the older JavaScript overlay frontend with a proper web client bundled from the Flutter web project at the Moonfin-Core repo and theme assets for a smoother setup.\n- Added a built-in theme editor at `/Moonfin/Web/theme/`, including admin-managed custom theme uploads and validation.\n- Added discovery endpoints for plugin web startup: `/Moonfin/Discovery` and `/Moonfin/Discovery/discover`.\n- Added custom theme APIs and services for listing, retrieving, uploading or replacing, deleting, and validating themes.\n- Improved settings sync and admin tools with better global/profile behavior and admin broadcast support.", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.9.0/Moonfin.Server-1.9.0.0.zip", + "checksum": "BCFEC3007BCCA81F535594FA8332FD3F", + "timestamp": "2026-06-01T02:47:41", + "dependencies": [ + + ] + }, + { + "version": "1.8.2.0", + "changelog": "## Features\n\n- Added Media Bar Enhanced support in desktop media bar settings. (#98)\n- Clicking a row item now navigates to the correct library, and playback from the Continue Watching row works properly again. (#60)\n\n## Bug Fixes\n\n- Details page buttons were too large on some screens — they\u0027ve been resized to something more reasonable. (#100)\n- Clicking items from third-party home screen section plugins no longer incorrectly triggers Moonfin\u0027s custom detail page. (#96)\n- Admin default settings now correctly propagate to existing users when changed. (#101)\n- The Jellyseerr discover page was crashing to an error screen because the proxy was wrapping responses in an unexpected JSON envelope. That\u0027s fixed now. (#108)\n- The global profile now saves reliably, the media bar state persists correctly across Moonfin and Paradox themes, and users no longer see genres or library sections they don\u0027t have access to. (#87)", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.8.2/Moonfin.Server-1.8.2.0.zip", + "checksum": "C559FB46DFE70DD27F151FB928D2F604", + "timestamp": "2026-04-19T20:07:23", + "dependencies": [ + + ] + }, + { + "version": "1.8.1.0", + "changelog": "## Bug Fixes\n\n- Fixed Moonfin UI not loading on Jellyfin instances deployed under a reverse proxy sub-path\n- Fixed Moonfin login detection using private Jellyfin API internals that could return falsy values depending on Jellyfin version, causing the plugin to never initialize\n- Fixed Jellyseerr proxy (cookies, HTML/CSS rewrites, injected script) using hardcoded root-relative paths that broke under reverse proxy sub-path deployments\n- Fixed Letterboxd ratings showing doubled values for entries cached prior to v1.7.0", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.8.1/Moonfin.Server-1.8.1.0.zip", + "checksum": "7B8C49B2D56C554818D402CD7D8DD583", + "timestamp": "2026-04-07T18:09:53", + "dependencies": [ + + ] + }, + { + "version": "1.8.0.0", + "changelog": "## Features\n\n- Added compatibility with @IAmParadox27 \u0027s [media bar](https://github.com/IAmParadox27/jellyfin-plugin-media-bar/) and [Home Screen Sections](https://github.com/IAmParadox27/jellyfin-plugin-home-sections) rows\n- Added support for @ranaldsgift \u0027s [KefinTweaks](https://github.com/ranaldsgift/KefinTweaks) rows\n\u003e [!NOTE] \n\u003e The rows are not yet implemented in the other clients. This is a manual method that will take time but the framework for implementation is done. The media bar compatibility is for web UI only under the desktop profile\n\n- Media bar went full screen like other clients\n- Added sync mode clarity dialog box\n- Added trailer overlay in details screen\n- Added details blur/opacity controls and new endpoints so it syncs with clients\n- Added special features, chapters, and collections in details\n- Added back button and fixed details tint behavior\n- Added favorite button for people details\n- Added favorite/watched indicators in details and library\n- Updated libraries UI\n- Updated collections to display contents in release order\n- Updated Moonfin plugin logo\n\n## Bug Fixes\n\n- Fixed details tint behavior that was causing the original jellyfin ui\u0027s backdrop image to look black\n- Fixed header bar glitch that was causing the library title to scroll with users in the library\n- Fixed plugin and UI not applying correctly (#60, #66, #76)\n- Fixed removed media bar items that did not have a backdrop and removed items that users did not have access to.\n- Fixed HDR badge truthy logic and expanded HDR range handling\n- Fixed trailer button forwards to home page\n- Fixed special features not showing with new details page\n- Fixed Seerr invalid CSRF token (#53, #63)\n- Fixed Seerr icon not showing on toolbar\n- Fixed Seerr integration slowness\n- Fixed /Moonfin/Settings returns 404 via reverse proxy", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.8.0/Moonfin.Server-1.8.0.0.zip", + "checksum": "BF2DA2868F49C577F14F2D61354F6281", + "timestamp": "2026-04-01T16:05:10", + "dependencies": [ + + ] + }, + { + "version": "1.7.1.0", + "changelog": "New Features:\n- Media Bar Genre Exclusions - exclude specific genres from the media bar so items from those genres won\u0027t appear, configurable per-user and as admin defaults\n- GET /Moonfin/Genres endpoint - returns all genre IDs and localized names for the genre picker\n\nBug Fixes:\n- Fixed Jellyseerr CSRF token errors causing failed authentication and proxy requests\n- Fixed Jellyseerr button not appearing when navbar/sidebar initializes after config is dispatched\n- Fixed media bar showing loading state indefinitely when no items are returned\n- Fixed media bar active class being added when there are no items to display\n- Fixed unnecessary Content-Type header on GET requests\n- Fixed duplicate event listeners on collection/library picker re-renders\n\nImprovements:\n- CSRF tokens are now fetched and sent for all Jellyseerr write operations with a fallback for IP-based URLs\n- Media bar loading class is now removed in error and empty states", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.7.1/Moonfin.Server-1.7.1.0.zip", + "checksum": "ECD1DE99E2A7CC34A4A7BD856848E073", + "timestamp": "2026-03-16T18:02:45", + "dependencies": [ + + ] + }, + { + "version": "1.7.0.0", + "changelog": "New Features:\n- Home Screen Row Ordering - drag and drop to reorder or hide home screen sections like Continue Watching, Next Up, and Latest Media\n- Media Bar Collections - choose specific collections or playlists to show in the media bar instead of pulling from all libraries\n- MDBList rating source labels can now be shown or hidden\n- Automatic settings change when switching between users on the same device\n\nNew Endpoint:\n- GET /Moonfin/MediaBar - returns media bar items based on user settings\n\nBug Fixes:\n- Fixed playback not working from the details page\n- Fixed media bar showing on pages other than the home screen\n- Fixed Seerr/Jellyseerr button not appearing after settings sync\n- Fixed details page interfering with media selection dialogs\n- Fixed incorrect Letterboxd rating values\n\nImprovements:\n- Reusable drag-and-drop sortable list component", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.7.0/Moonfin.Server-1.7.0.0.zip", + "checksum": "B698F944D017AD4675C16C222F8BB25D", + "timestamp": "2026-03-15T03:21:44", + "dependencies": [ + + ] + }, + { + "version": "1.6.0.0", + "changelog": "Bug Fixes:\n- Fixed Seerr/Jellyseerr proxy navigation - search, sidebar links, and in-app navigation no longer load indefinitely\n- Fixed user settings not saving correctly - refactored settings mapping for consistency\n- Fixed navbar background to use a gradient\n- Fixed admin config page not appearing in Jellyfin admin sidebar\n\nImprovements:\n- Media version/quality information now displayed on details overlay\n- Authentication now requires only a username for passwordless Jellyfin users\n- MDBList batch task refactored for better memory management with stream-based JSON I/O", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.6.0/Moonfin.Server-1.6.0.0.zip", + "checksum": "1764D016C791637BA0182F5662456B54", + "timestamp": "2026-03-14T12:47:13", + "dependencies": [ + + ] + }, + { + "version": "1.5.1.0", + "changelog": "Bug Fixes:\n- Fixed MDBList and TMDB user API keys not being read from v2 settings profiles (only admin keys worked)\n- Fixed user-selected MDBList rating sources being ignored after v1-to-v2 settings migration (only default 4 sources were returned)", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.5.1/Moonfin.Server-1.5.1.0.zip", + "checksum": "14CAC55434728A5E92CD2481E85DE152", + "timestamp": "2026-02-23T04:33:48", + "dependencies": [ + + ] + }, + { + "version": "1.5.0.0", + "changelog": "Features:\n- Device-specific settings profiles (global, desktop, mobile, TV) with inheritance and sync\n- Admin-configurable default user settings with per-user overrides\n- Sync toggle for enabling/disabling server settings synchronization\n\nImprovements:\n- Season posters fall back to series poster when unavailable (#15)\n- Lazy trailer fetching per displayed item instead of bulk RemoteTrailers query\n\nBug Fixes:\n- Fixed media bar auto-advance and trailer playback continuing after navigating away from home\n- Fixed media bar not stopping on admin pages and video player", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.5.0/Moonfin.Server-1.5.0.0.zip", + "checksum": "43D12A1CC3EBF5CDED1BC974BC925492", + "timestamp": "2026-02-22T18:33:27", + "dependencies": [ + + ] + }, + { + "version": "1.4.0.0", + "changelog": "Features:\n- Media bar trailer previews using YouTube IFrame API with SponsorBlock integration\n- Media bar redesign with logos, rating badges, and genre pills\n\nImprovements:\n- Seerr v3 support with auto-detection and direct iframe URL option\n- Server-wide TMDB API key with user notifications\n- Dynamic icon swapping for Jellyseerr/Seerr\n- Sidebar: settings moved under libraries with separator\n- Admin panel no longer blocked by plugin (whitelist-based route detection)\n- Sensitive data masked in console logs", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.4.0/Moonfin.Server-1.4.0.0.zip", + "checksum": "9E826973350BC5B2B56957F45D19EC56", + "timestamp": "2026-02-19T05:32:55", + "dependencies": [ + + ] + }, + { + "version": "1.3.1.0", + "changelog": "- Seerr v3 support with auto-detection (version-based)\n- Direct iframe URL option for Seerr behind reverse proxies\n- Server-wide TMDB API key with user notifications\n- Removed Rotten Tomatoes rating from details page\n- Added dynamic icon swapping for Jellyseerr/Seerr\n- Updated README with Seerr v3 reverse proxy documentation", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.3.1/Moonfin.Server-1.3.1.0.zip", + "checksum": "CAEF64ED665CD3C324F83B3EA490D281", + "timestamp": "2026-02-15T12:50:46", + "dependencies": [ + + ] + }, + { + "version": "1.3.0.0", + "changelog": "- Left sidebar navigation (configurable via navbarPosition)\n- Three-way settings merge with snapshot-based conflict resolution\n- MDBList server-side caching and batch pre-fetching\n\nImprovements:\n- PNG to SVG migration for all rating icons\n- Updated README and admin config page\n\nBug Fixes:\n- Fixed Jellyseerr session persistence (JSON deserialization)\n- Fixed Jellyseerr sessions on local IP addresses\n- Fixed Jellyseerr cookie rotation causing unexpected logouts\n- Fixed mobile media bar overlapping first library row", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.3.0/Moonfin.Server-1.3.0.0.zip", + "checksum": "39CC6BA712E02E2E5552A792CC1BF280", + "timestamp": "2026-02-14T22:00:16", + "dependencies": [ + + ] + }, + { + "version": "1.2.0.0", + "changelog": "- TMDB episode ratings with server-side caching\n- Drag \u0026 drop sortable rating sources\n- Jellyseerr dual login (Jellyfin + local accounts)\n- Edit Metadata/Images/Subtitles/Identify open as native dialogs\n- Create New Playlist from details page\n- Fixed homescreen context menu interception\n- Fixed subtitle picker triggering details overlay during playback", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.2.0/Moonfin.Server-1.2.0.0.zip", + "checksum": "FEBE25F651728978B685E8734E8B4953", + "timestamp": "2026-02-12T22:27:49", + "dependencies": [ + + ] + }, + { + "version": "1.1.1.0", + "changelog": "- Fixed Jellyseerr 404 when clicking links in iframe\n- Plugin now auto-registers file transformations on load (no manual task needed after updates)", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.1.1/Moonfin.Server-1.1.1.0.zip", + "checksum": "6F034F273541D5103C59B85346B275D4", + "timestamp": "2026-02-11T08:02:04", + "dependencies": [ + + ] + }, + { + "version": "1.1.0.0", + "changelog": "- Integrated File Transformation plugin for automatic web UI injection\n- Fixed bug where media bar did not load right away\n- Added server-wide MDBList API key support\n- Updated user settings and UI", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.1.0/Moonfin.Server-1.1.0.0.zip", + "checksum": "E635BA9B123DD5C8EB9AA32140FBA43D", + "timestamp": "2026-02-09T22:58:42", + "dependencies": [ + + ] + }, + { + "version": "1.0.0.0", + "changelog": "Initial release with core features: navbar, media bar, custom details screens, IMDBList integration, Jellyseerr integration, and settings sync.\n Hope you like it!", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/Moonfin-Client/Plugin/releases/download/1.0.0/Moonfin.Server-1.0.0.0.zip", + "checksum": "D96BC4F769AC38BA298258C0EF21F300", + "timestamp": "2026-02-11T07:59:01", + "dependencies": [ + + ] + } + ] +} From aec99a9bcf3fb00e4a00da6cd16b1cdbe5750838 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:11 -0700 Subject: [PATCH 03/10] Update backend/Services/CustomRowCacheService.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Services/CustomRowCacheService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/Services/CustomRowCacheService.cs b/backend/Services/CustomRowCacheService.cs index f264b14..c180c3d 100644 --- a/backend/Services/CustomRowCacheService.cs +++ b/backend/Services/CustomRowCacheService.cs @@ -153,6 +153,9 @@ public class CustomRowItem [JsonPropertyName("userRating")] public string? UserRating { get; set; } + [JsonPropertyName("rating")] + public double? Rating { get; set; } + [JsonPropertyName("posterUrl")] public string? PosterUrl { get; set; } From 87a2d3121bed077970585356d2a07dd45c7ef3d6 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:18 -0700 Subject: [PATCH 04/10] Update backend/Api/CustomRowController.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Api/CustomRowController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 2c55a0c..4d0162b 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -815,6 +815,7 @@ private async Task> FetchLetterboxd( Tmdb = pItem.TmdbId }, UserRating = stars, + Rating = pItem.Rating, PosterUrl = pItem.PosterUrl, BackdropUrl = pItem.BackdropUrl }); From fd4b60097b51ab2e0f6d197cc02c9af3c0e35e12 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:25 -0700 Subject: [PATCH 05/10] Update backend/Api/CustomRowController.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Api/CustomRowController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 4d0162b..70d49df 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -805,7 +805,7 @@ private async Task> FetchLetterboxd( var stars = pItem.Rating.HasValue ? FormatRatingToStars(pItem.Rating.Value) : null; items.Add(new CustomRowItem { - Id = long.Parse(pItem.TmdbId), + Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, Name = pItem.Title, Type = "Movie", // Letterboxd is strictly movies ProductionYear = pItem.Year, From 3a9c42c8ab5d931c6c813fe7c06eef67a6d52f5b Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:31 -0700 Subject: [PATCH 06/10] Update backend/Api/CustomRowController.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Api/CustomRowController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 70d49df..cd001a5 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -514,7 +514,7 @@ private async Task> FetchTmdbChart( var backdropPath = item.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { - Id = long.Parse(itemId), + Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = finalType, ProductionYear = year, From 68244246a339000f59f8cc0c25f0c2f14969ddc8 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:38 -0700 Subject: [PATCH 07/10] Update backend/Api/CustomRowController.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Api/CustomRowController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index cd001a5..126e474 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -421,7 +421,7 @@ private async Task> FetchTmdb( var backdropPath = item.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { - Id = long.Parse(itemId), + Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = finalType, ProductionYear = year, From a482b406c476847ebfddea10bfb701f9208cf19e Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:44 -0700 Subject: [PATCH 08/10] Update backend/Api/CustomRowController.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Api/CustomRowController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 126e474..2625306 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -370,7 +370,7 @@ private async Task> FetchTmdb( var backdropPath = part.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { - Id = long.Parse(partId), + Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = "Movie", ProductionYear = year, From deffb4976e7fb5aa3897165ea8a7a19f0c6586e8 Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Sun, 28 Jun 2026 17:13:51 -0700 Subject: [PATCH 09/10] Update backend/Api/CustomRowController.cs Co-authored-by: Axl <103554043+RadicalMuffinMan@users.noreply.github.com> --- backend/Api/CustomRowController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 2625306..17dd274 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -234,7 +234,7 @@ private async Task> FetchMdbList( items.Add(new CustomRowItem { - Id = string.IsNullOrWhiteSpace(tmdbId) ? null : long.Parse(tmdbId), + Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = finalType, ProductionYear = year, From f9bab1241e2670fd0518857e4059da0b32fbf65a Mon Sep 17 00:00:00 2001 From: mattsigal Date: Sun, 28 Jun 2026 17:24:36 -0700 Subject: [PATCH 10/10] refactor(letterboxd): disable direct HTML crawling and resolve TMDb IDs from RSS namespace - Throw ArgumentException for watchlist, films, and user_list requests due to Letterboxd Terms of Service. - Extract tmdb:movieId directly from the RSS feed, avoiding any film page HTML scraping. - Remove letterboxd_slug_to_tmdb.json persistent cache and helper methods. --- backend/Api/CustomRowController.cs | 278 ++++------------------------- 1 file changed, 39 insertions(+), 239 deletions(-) diff --git a/backend/Api/CustomRowController.cs b/backend/Api/CustomRowController.cs index 17dd274..fe7c9a3 100644 --- a/backend/Api/CustomRowController.cs +++ b/backend/Api/CustomRowController.cs @@ -23,11 +23,6 @@ public class CustomRowController : ControllerBase private readonly ILogger _logger; private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); - - // Persistent file mapping Letterboxd slugs to TMDb IDs - private readonly string _letterboxdSlugsFilePath; - private static ConcurrentDictionary? _letterboxdSlugMap; - private static readonly SemaphoreSlim _slugFileLock = new(1, 1); public CustomRowController( MoonfinSettingsService settingsService, @@ -39,16 +34,6 @@ public CustomRowController( _cacheService = cacheService; _httpClientFactory = httpClientFactory; _logger = logger; - - var dataPath = MoonfinPlugin.Instance?.DataFolderPath - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Jellyfin", "plugins", "Moonfin"); - - if (!Directory.Exists(dataPath)) - { - Directory.CreateDirectory(dataPath); - } - - _letterboxdSlugsFilePath = Path.Combine(dataPath, "letterboxd_slug_to_tmdb.json"); } [HttpGet("Items")] @@ -234,7 +219,7 @@ private async Task> FetchMdbList( items.Add(new CustomRowItem { - Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, + Id = long.TryParse(tmdbId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = finalType, ProductionYear = year, @@ -370,7 +355,7 @@ private async Task> FetchTmdb( var backdropPath = part.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { - Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, + Id = long.TryParse(partId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = "Movie", ProductionYear = year, @@ -421,7 +406,7 @@ private async Task> FetchTmdb( var backdropPath = item.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { - Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, + Id = long.TryParse(itemId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = finalType, ProductionYear = year, @@ -514,7 +499,7 @@ private async Task> FetchTmdbChart( var backdropPath = item.TryGetProperty("backdrop_path", out var bProp) ? bProp.GetString() : null; items.Add(new CustomRowItem { - Id = long.TryParse(pItem.TmdbId, out var lbTmdbId) ? lbTmdbId : null, + Id = long.TryParse(itemId, out var lbTmdbId) ? lbTmdbId : null, Name = title, Type = finalType, ProductionYear = year, @@ -543,32 +528,9 @@ private async Task> FetchLetterboxd( paramsMap.TryGetValue("name", out var listname); paramsMap.TryGetValue("url", out var fullUrl); - if (type == "user_list") + if (type == "user_list" || type == "watchlist" || type == "films") { - if (string.IsNullOrWhiteSpace(fullUrl)) - { - throw new ArgumentException("Letterboxd URL lists require a URL."); - } - - // Normalise URL / path (e.g. /official/list/letterboxds-top-500-films/ or https://letterboxd.com/...) - var cleanedUrl = fullUrl.Replace("https://", "").Replace("http://", "").Replace("www.", "").Replace("letterboxd.com/", ""); - if (cleanedUrl.StartsWith('/')) cleanedUrl = cleanedUrl.Substring(1); - - var parts = cleanedUrl.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && parts[1] == "list") - { - username = parts[0]; - listname = parts[2]; - } - else if (parts.Length >= 2) - { - username = parts[0]; - listname = parts[1]; - } - else - { - throw new ArgumentException("Invalid Letterboxd list URL or path format."); - } + throw new ArgumentException("Direct HTML scraping of Letterboxd watchlists and lists is disabled due to their Terms of Service. Please import your list into MDBList and use the MDBList custom row source instead."); } if (username != null) @@ -576,168 +538,59 @@ private async Task> FetchLetterboxd( username = username.ToLowerInvariant().Trim(); } - if (string.IsNullOrWhiteSpace(username) && type != "user_list") + if (string.IsNullOrWhiteSpace(username)) { throw new ArgumentException("Letterboxd requires user parameter."); } - var isHtmlCrawled = type == "user_list" || type == "watchlist" || type == "films"; - var baseUrl = type switch - { - "watchlist" => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/watchlist/", - "films" => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/films/", - "user_list" => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/list/{Uri.EscapeDataString(listname ?? "")}/", - _ => $"https://letterboxd.com/{Uri.EscapeDataString(username ?? "")}/rss/" - }; + var baseUrl = $"https://letterboxd.com/{Uri.EscapeDataString(username)}/rss/"; var client = CreateClient(); var items = new List(); - EnsureSlugsLoaded(); var parsedFeedItems = new List(); - if (isHtmlCrawled) + using var response = await client.GetAsync(baseUrl, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) { - for (int page = 1; page <= 5; page++) - { - var pageUrl = page == 1 - ? baseUrl - : $"{baseUrl}page/{page}/"; - - using var pageResp = await client.GetAsync(pageUrl, cancellationToken).ConfigureAwait(false); - if (!pageResp.IsSuccessStatusCode) - { - break; - } - - var htmlContent = await pageResp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var matches = Regex.Matches(htmlContent, @"data-target-link=""/film/([^/]+)/"""); - if (matches.Count == 0) - { - break; - } - - foreach (Match match in matches) - { - if (match.Success) - { - var slug = match.Groups[1].Value; - if (!parsedFeedItems.Any(i => i.Slug == slug)) - { - parsedFeedItems.Add(new LetterboxdFeedItem - { - Title = slug, - Slug = slug - }); - } - } - } - } + throw new Exception($"Letterboxd returned status {(int)response.StatusCode} for {baseUrl}"); } - else - { - using var response = await client.GetAsync(baseUrl, cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Letterboxd returned status {(int)response.StatusCode} for {baseUrl}"); - } - var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var document = XDocument.Parse(xmlContent); - var rssItems = document.Descendants("item"); - - foreach (var rssItem in rssItems) - { - var title = rssItem.Element("title")?.Value ?? "Unknown"; - var link = rssItem.Element("link")?.Value ?? ""; - - var filmTitle = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmTitle")?.Value ?? title; - var filmYearStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmYear")?.Value; - var memberRatingStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "memberRating")?.Value; + var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var document = XDocument.Parse(xmlContent); + var rssItems = document.Descendants("item"); - int? year = null; - if (int.TryParse(filmYearStr, out var y)) year = y; - - double? rating = null; - if (double.TryParse(memberRatingStr, out var r)) rating = r; + foreach (var rssItem in rssItems) + { + var title = rssItem.Element("title")?.Value ?? "Unknown"; + var link = rssItem.Element("link")?.Value ?? ""; + + var filmTitle = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmTitle")?.Value ?? title; + var filmYearStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "filmYear")?.Value; + var memberRatingStr = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "memberRating")?.Value; + var resolvedTmdbId = rssItem.Elements().FirstOrDefault(e => e.Name.LocalName == "movieId")?.Value; - var slugMatch = Regex.Match(link, @"film/([^/]+)/?"); - if (slugMatch.Success) - { - var slug = slugMatch.Groups[1].Value; - parsedFeedItems.Add(new LetterboxdFeedItem - { - Title = filmTitle, - Year = year, - Rating = rating, - Slug = slug - }); - } - } - } + int? year = null; + if (int.TryParse(filmYearStr, out var y)) year = y; - // 2. Resolve TMDB IDs for each slug - var slugsToFetch = new List(); - foreach (var pItem in parsedFeedItems) - { - if (_letterboxdSlugMap!.TryGetValue(pItem.Slug, out var tmdbId)) - { - pItem.TmdbId = tmdbId; - } - else - { - slugsToFetch.Add(pItem.Slug); - } - } + double? rating = null; + if (double.TryParse(memberRatingStr, out var r)) rating = r; - // Fetch unresolved slugs in parallel (max 10 concurrent requests) to avoid timeouts - if (slugsToFetch.Count > 0) - { - using var slugSemaphore = new SemaphoreSlim(10, 10); - var tasks = slugsToFetch.Select(async slug => + var slugMatch = Regex.Match(link, @"film/([^/]+)/?"); + if (slugMatch.Success) { - await slugSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // Stagger request startup slightly to prevent instant bursts - var delayMs = new Random().Next(30, 250); - await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); - - var filmUrl = $"https://letterboxd.com/film/{slug}/"; - using var filmResp = await client.GetAsync(filmUrl, cancellationToken).ConfigureAwait(false); - if (filmResp.IsSuccessStatusCode) - { - var filmHtml = await filmResp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var tmdbMatch = Regex.Match(filmHtml, @"data-tmdb-id=""(\d+)"""); - if (tmdbMatch.Success) - { - var resolvedId = tmdbMatch.Groups[1].Value; - _letterboxdSlugMap![slug] = resolvedId; - - lock (parsedFeedItems) - { - foreach (var pItem in parsedFeedItems.Where(i => i.Slug == slug)) - { - pItem.TmdbId = resolvedId; - } - } - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to resolve TMDb ID for Letterboxd slug: {Slug}", slug); - } - finally + var slug = slugMatch.Groups[1].Value; + parsedFeedItems.Add(new LetterboxdFeedItem { - slugSemaphore.Release(); - } - }); - - await Task.WhenAll(tasks).ConfigureAwait(false); - await FlushSlugsAsync().ConfigureAwait(false); + Title = filmTitle, + Year = year, + Rating = rating, + Slug = slug, + TmdbId = resolvedTmdbId + }); + } } - // 2.5 Fetch full details/posters from TMDb API in parallel (max 15 concurrent requests) + // Fetch full details/posters from TMDb API in parallel (max 15 concurrent requests) var resolvedProfile = await _settingsService.GetResolvedProfileAsync(userId, "global"); var tmdbKey = resolvedProfile?.TmdbApiKey; if (string.IsNullOrWhiteSpace(tmdbKey)) @@ -834,60 +687,7 @@ private static string FormatRatingToStars(double rating) return stars; } - private void EnsureSlugsLoaded() - { - if (_letterboxdSlugMap != null) return; - - _slugFileLock.Wait(); - try - { - if (_letterboxdSlugMap != null) return; - - if (System.IO.File.Exists(_letterboxdSlugsFilePath)) - { - try - { - var json = System.IO.File.ReadAllText(_letterboxdSlugsFilePath); - var dict = JsonSerializer.Deserialize>(json); - _letterboxdSlugMap = dict != null - ? new ConcurrentDictionary(dict, StringComparer.OrdinalIgnoreCase) - : new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - catch - { - _letterboxdSlugMap = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - } - else - { - _letterboxdSlugMap = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - } - finally - { - _slugFileLock.Release(); - } - } - - private async Task FlushSlugsAsync() - { - if (_letterboxdSlugMap == null) return; - await _slugFileLock.WaitAsync().ConfigureAwait(false); - try - { - var json = JsonSerializer.Serialize(_letterboxdSlugMap); - await System.IO.File.WriteAllTextAsync(_letterboxdSlugsFilePath, json).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to flush Letterboxd slugs map to disk"); - } - finally - { - _slugFileLock.Release(); - } - } private HttpClient CreateClient() {