commit 63dbc3a6d4d3edc374f1897368b2cab8fdaf1063 Author: ajp_anton Date: Sun May 31 18:28:04 2026 +0000 Initial jellyfin-multilang rewrite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..040ea51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +bin/ +obj/ +publish/ +.vs/ +.vscode/ +__pycache__/ +*.user +*.suo +deploy.local.json + +# Local planning/deployment/reference files +PROJECT.md +Update.py +old-attempt/ + +# Generated artifacts +*.pyc +*.sqlite diff --git a/README.md b/README.md new file mode 100644 index 0000000..53f04ec --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Jellyfin Multilang + +Multilang is a Jellyfin plugin that lets different users see different metadata +languages without changing Jellyfin's stored metadata. + +The plugin is built for Jellyfin 10.11.9 and is currently early beta software. + +## What It Does + +Multilang can fetch and display metadata and artwork in chosen languages for: + +- Titles +- Overviews +- Taglines +- Posters +- Logos +- Banners +- Thumbnails +- Backdrops + +In addition, it can translate genres into the user's Jellyfin display language. + +## Admin Features + +Admins configure the data that Multilang can use: + +- Choose which languages should be exposed for metadata and artwork fetching. +- Configure TMDb and Fanart.tv provider keys. +- Set metadata and artwork provider priority. +- Configure leading articles for sorting, such as `the`, `a`, `an`, or + language-specific equivalents. +- Configure scheduled refresh timing for missing and existing metadata. +- Set thresholds for what requests to cache. +- View cache and recent request diagnostics. +- Enable logging when troubleshooting. + +## User Features + +Each user can enable or disable Multilang for themselves. + +Users can create classification rules that group items into categories. For +example, a user can classify Finnish films, Swedish films, Spanish films, or +movies produced in a specific country. + +For each category, the user can choose an ordered fallback list for each field. +For example: + +- Use the Finnish title, then Swedish if that isn't available, then Jellyfin's default as the final fallback. +- Use the original title instead of any set language. +- Use a Swedish overview, then English if that isn't available. +- Use an original-language poster, then Jellyfin's default poster if that isn't available. +- Clear a field if no selected language has data. + +Title and poster always force Jellyfin as a final fallback so browsing remains +usable. + +Users can also choose which language's alphabet and sorting rules should be used +when Multilang sorts item lists. + +## How It Works + +The admin chooses which languages Multilang should make available. These +languages decide what metadata and artwork the plugin fetches from providers. + +Each user then creates their own rules for what languages to display, when to +display them, and which fields should use them. The available choices are: + +- Languages selected by the admin. +- The `Original` language according to TMDb. +- Jellyfin's own default metadata and artwork. + +Multilang keeps fetched text metadata in its own SQLite database. Downloaded or +proxied artwork is handled by the plugin separately from Jellyfin's normal +metadata storage. Jellyfin's own library metadata is not overwritten. + +The plugin changes the Jellyfin web UI by injecting JavaScript into Jellyfin's +main web page. This works in a browser and in the official Jellyfin desktop app, +because both use the Jellyfin web UI. It is not guaranteed to work in clients +that do not use the web UI. + +The injected script makes selected UI requests go through Multilang's plugin API +instead of directly to Jellyfin. The plugin fetches the original response from +Jellyfin, applies the current user's rules, and returns a modified response to +the UI. + +Some requests can be slow because Multilang may need to ask Jellyfin for a +larger list than the UI will finally display. This is needed so the plugin can +translate, filter, sort, and slice the list using the user's own rules. +Multilang caches slow transformed responses for a configurable time and also +uses a very short burst cache for repeated requests during navigation. Cache +entries are invalidated when affected items are refreshed. + +## Original-Language Support + +Multilang stores metadata and artwork tagged as `Original`. + +This is useful when a user wants the original title and matching original poster +for languages they do not otherwise want as their main metadata language. + +## Genres + +Jellyfin stores and displays genres by name. If metadata was fetched in English, +the genre may be `Drama`; if it was fetched in Finnish, the genre may be +`Draama`. Jellyfin treats those names as separate genres, and it displays the +stored name regardless of the user's display language. + +Multilang avoids this by storing TMDb genre IDs for each item. The ID stays the +same across languages, so the plugin can display the genre name in the user's +current Jellyfin display language when a translation is available. + +## Debugging + +The plugin includes lightweight admin/debug tools: + +- Cache diagnostics +- Recent request diagnostics +- Refresh queue/provider diagnostics +- Per-item debug view showing which category matched and which field action won + +These are intended for troubleshooting configuration or missing metadata, not +for normal daily use. + +## Current Status + +This plugin is usable but still under active testing. The most important areas +to test are: + +- Large library browsing +- Sorting and filtering +- Collections +- Shows, seasons, and episodes +- Scheduled scans +- Complex per-user classification rules + +## AI Assistance + +This codebase was written with substantial help from AI tools, under human +direction and testing. diff --git a/global.json b/global.json new file mode 100644 index 0000000..12d3d54 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.116", + "rollForward": "latestFeature" + } +} diff --git a/jellyfin-multilang.sln b/jellyfin-multilang.sln new file mode 100644 index 0000000..b472427 --- /dev/null +++ b/jellyfin-multilang.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D1AD3B53-6123-4F98-87D5-517B5BF6ADAF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Multilang", "src\Jellyfin.Plugin.Multilang\Jellyfin.Plugin.Multilang.csproj", "{138DB6FA-E217-41B3-A9D1-F24FB724DD44}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {138DB6FA-E217-41B3-A9D1-F24FB724DD44} = {D1AD3B53-6123-4F98-87D5-517B5BF6ADAF} + EndGlobalSection +EndGlobal diff --git a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs new file mode 100644 index 0000000..984d989 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs @@ -0,0 +1,2137 @@ +using System.Reflection; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Jellyfin.Plugin.Multilang.Configuration; +using Jellyfin.Plugin.Multilang.Data; +using Jellyfin.Plugin.Multilang.Rules; +using Jellyfin.Plugin.Multilang.Services; +using Jellyfin.Plugin.Multilang.Services.Providers; +using Jellyfin.Plugin.Multilang.Services.Refresh; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Multilang.Api; + +[ApiController] +[Route("Multilang")] +public sealed class MultilangController : ControllerBase +{ + private static readonly string[] RequirementFieldOrder = + [ + "original_language", + "spoken_languages", + "audio_language", + "origin_countries", + "production_countries" + ]; + + private readonly TranslationStore _store; + private readonly ISessionManager _sessionManager; + private readonly IAuthorizationContext _authorizationContext; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IApplicationPaths _appPaths; + private readonly ProviderCatalog _providerCatalog; + private readonly SortArticleCatalog _articleCatalog; + private readonly RefreshService _refreshService; + private readonly FanartClient _fanartClient; + private readonly ItemsProxyCache _itemsProxyCache; + private readonly ILogger _logger; + + public MultilangController( + TranslationStore store, + ISessionManager sessionManager, + IAuthorizationContext authorizationContext, + ILibraryManager libraryManager, + IUserManager userManager, + IHttpClientFactory httpClientFactory, + IApplicationPaths appPaths, + ProviderCatalog providerCatalog, + SortArticleCatalog articleCatalog, + RefreshService refreshService, + FanartClient fanartClient, + ItemsProxyCache itemsProxyCache, + ILogger logger) + { + _store = store; + _sessionManager = sessionManager; + _authorizationContext = authorizationContext; + _libraryManager = libraryManager; + _userManager = userManager; + _httpClientFactory = httpClientFactory; + _appPaths = appPaths; + _providerCatalog = providerCatalog; + _articleCatalog = articleCatalog; + _refreshService = refreshService; + _fanartClient = fanartClient; + _itemsProxyCache = itemsProxyCache; + _logger = logger; + } + + [HttpGet("inject.js")] + public IActionResult InjectJs() + => ServeEmbedded("Jellyfin.Plugin.Multilang.inject.js", "application/javascript"); + + [HttpGet("shared.js")] + public IActionResult SharedJs() + => ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.js", "application/javascript"); + + [HttpGet("shared.css")] + public IActionResult SharedCss() + => ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css"); + + [HttpGet("Providers")] + public IActionResult Providers() + => Ok(_providerCatalog.Providers); + + [HttpGet("ArticleBuiltins")] + public IActionResult ArticleBuiltins() + => Ok(_articleCatalog.GetBuiltIns().OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(k => k.Key, v => v.Value)); + + [HttpGet("AdminConfig")] + public async Task AdminConfig() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + return Ok(AdminConfigDto.FromConfiguration(cfg)); + } + + [HttpPost("AdminConfig")] + public async Task SaveAdminConfig([FromBody] AdminConfigDto request) + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + var plugin = Plugin.Instance; + if (plugin is null) + return StatusCode(500, new { Error = "PluginUnavailable" }); + + var previous = plugin.Configuration; + var cfg = request.ToConfiguration(_providerCatalog); + plugin.UpdateConfiguration(cfg); + if (AdminConfigAffectsCachedItems(previous, cfg)) + _itemsProxyCache.ClearAll(); + + return Ok(AdminConfigDto.FromConfiguration(cfg)); + } + + private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next) + => !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) || + !ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries); + + private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer) + => previous.OrderBy(v => v, comparer).SequenceEqual(next.OrderBy(v => v, comparer), comparer); + + private static bool ArticleEntriesEqual(SortArticleEntry[] previous, SortArticleEntry[] next) + { + var previousEntries = previous + .OrderBy(e => e.Language, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Articles, StringComparer.Ordinal) + .ThenBy(e => e.AlwaysApply) + .ToArray(); + var nextEntries = next + .OrderBy(e => e.Language, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Articles, StringComparer.Ordinal) + .ThenBy(e => e.AlwaysApply) + .ToArray(); + + if (previousEntries.Length != nextEntries.Length) + return false; + + for (var i = 0; i < previousEntries.Length; i++) + { + if (!previousEntries[i].Language.Equals(nextEntries[i].Language, StringComparison.OrdinalIgnoreCase) || + !previousEntries[i].Articles.Equals(nextEntries[i].Articles, StringComparison.Ordinal) || + previousEntries[i].AlwaysApply != nextEntries[i].AlwaysApply) + { + return false; + } + } + + return true; + } + + [HttpGet("UserRules/self")] + public async Task GetSelfRules() + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + return Ok(GetRulesOrDefault(userId)); + } + + [HttpPost("UserRules/self")] + public async Task SaveSelfRules([FromBody] UserRulesDocument request) + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + var allowed = (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); + request.Categories = NormalizeCategories(request.Categories, allowed); + request.FallbackFieldActions = NormalizeActionLists(request.FallbackFieldActions, allowed); + request.FallbackLanguages = GetNeededLanguages(request); + _store.SaveUserRules(userId, request); + _itemsProxyCache.ClearAll(); + return Ok(request); + } + + [HttpGet("UserRulesUi")] + public IActionResult UserRulesUi() + { + Response.Headers["Content-Security-Policy"] = + "default-src 'self'; base-uri 'self'; form-action 'self'; img-src 'self' data:; " + + "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self';"; + var fragment = ReadEmbedded("Jellyfin.Plugin.Multilang.Configuration.userRulesPage.html"); + if (fragment is null) + return NotFound(); + return Content("Multilang User Rules" + fragment + "", "text/html"); + } + + [HttpGet("DebugUi/{itemId}")] + public IActionResult DebugUi(string itemId) + { + Response.Headers["Content-Security-Policy"] = + "default-src 'self'; base-uri 'self'; form-action 'self'; img-src 'self' data:; " + + "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self';"; + var fragment = ReadEmbedded("Jellyfin.Plugin.Multilang.Configuration.debugPage.html"); + if (fragment is null) + return NotFound(); + return Content("Multilang Debug" + fragment + "", "text/html"); + } + + [HttpGet("AllowedLanguages")] + public async Task AllowedLanguages() + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + return Ok(new { Languages = Plugin.Instance?.Configuration?.Languages ?? [] }); + } + + [HttpPost("RefreshItem/{itemId}")] + public async Task RefreshItem(string itemId, [FromQuery] bool includeChildren = true, CancellationToken cancellationToken = default) + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + if (!TryParseItemId(itemId, out _, out var itemGuid)) + return BadRequest(new { Error = "InvalidItemId" }); + + await _refreshService.RefreshItemAsync(itemGuid, includeChildren, cancellationToken).ConfigureAwait(false); + return Ok(new { ItemId = itemGuid.ToString("N"), IncludeChildren = includeChildren }); + } + + [HttpGet("ItemsProxy")] + public async Task ItemsProxy([FromQuery] string url, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(url)) + return BadRequest(new { Error = "MissingUrl" }); + + var token = GetTokenFromRequest(); + if (string.IsNullOrWhiteSpace(token)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + var rules = GetRulesOrDefault(userId); + var proxyRequest = BuildProxyRequest(url, token, userId, rules.Enabled); + if (proxyRequest is null) + return BadRequest(new { Error = "InvalidUrl" }); + + var totalSw = Stopwatch.StartNew(); + var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0; + var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes)); + if (cacheAllowed && _itemsProxyCache.TryGet(proxyRequest.CacheKey, cacheTtl, out var cached)) + { + totalSw.Stop(); + _itemsProxyCache.RecordRequest( + proxyRequest.NormalizedUrlForCache, + cacheHit: true, + cacheStored: false, + statusCode: 200, + itemCount: cached.ItemCount, + totalMs: totalSw.ElapsedMilliseconds, + upstreamMs: 0, + transformMs: 0, + sizeBytes: cached.SizeBytes); + return Content(cached.Body, cached.ContentType); + } + + if (rules.Enabled && proxyRequest.IsGenresRequest && TryBuildGenresResponse(proxyRequest.GenreMedia, proxyRequest.Controls.ClientLocale, out var genresBody)) + { + totalSw.Stop(); + var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(genresBody); + _itemsProxyCache.RecordRequest( + proxyRequest.NormalizedUrlForCache, + cacheHit: false, + cacheStored: false, + statusCode: 200, + itemCount: CountRootArrayItems(genresBody), + totalMs: totalSw.ElapsedMilliseconds, + upstreamMs: 0, + transformMs: totalSw.ElapsedMilliseconds, + sizeBytes: sizeBytes); + return Content(genresBody, "application/json"); + } + + var upstreamSw = Stopwatch.StartNew(); + var http = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream); + request.Headers.TryAddWithoutValidation("X-Emby-Token", token); + request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token); + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + upstreamSw.Stop(); + string[] responseItemIds = []; + var transformMs = 0L; + if (response.IsSuccessStatusCode && rules.Enabled) + { + var transformSw = Stopwatch.StartNew(); + body = TransformItemsResponse(body, rules, proxyRequest.Controls, out responseItemIds); + transformSw.Stop(); + transformMs = transformSw.ElapsedMilliseconds; + } + totalSw.Stop(); + + var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"; + var bodySizeBytes = System.Text.Encoding.UTF8.GetByteCount(body); + var cacheStored = false; + if (cacheAllowed && + response.IsSuccessStatusCode && + responseItemIds.Length > 0 && + totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs)) + { + cacheStored = _itemsProxyCache.Store( + proxyRequest.CacheKey, + userId, + proxyRequest.NormalizedUrlForCache, + responseItemIds, + body, + contentType, + totalSw.ElapsedMilliseconds, + cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L, + cacheTtl); + } + + if (cacheAllowed && + !cacheStored && + response.IsSuccessStatusCode && + responseItemIds.Length > 0) + { + _itemsProxyCache.StoreMicro( + proxyRequest.CacheKey, + userId, + proxyRequest.NormalizedUrlForCache, + responseItemIds, + body, + contentType, + totalSw.ElapsedMilliseconds, + cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L); + } + + _itemsProxyCache.RecordRequest( + proxyRequest.NormalizedUrlForCache, + cacheHit: false, + cacheStored: cacheStored, + statusCode: (int)response.StatusCode, + itemCount: responseItemIds.Length, + totalMs: totalSw.ElapsedMilliseconds, + upstreamMs: upstreamSw.ElapsedMilliseconds, + transformMs: transformMs, + sizeBytes: bodySizeBytes); + + return new ContentResult + { + StatusCode = (int)response.StatusCode, + Content = body, + ContentType = contentType + }; + } + + private string TransformItemsResponse(string body, UserRulesDocument rules, ItemsProxyControls controls, out string[] responseItemIds) + { + responseItemIds = []; + JsonNode? root; + try + { + root = JsonNode.Parse(body); + } + catch (JsonException) + { + return body; + } + + if (root is null) + return body; + + var items = CollectItemObjects(root).ToArray(); + var ids = items + .Select(TryGetItemId) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + responseItemIds = ids; + if (ids.Length == 0) + return body; + + var sortLocale = ResolveSortLocale(rules.SortLocale, controls.ClientLocale); + var sortCulture = GetSortCulture(sortLocale); + var sortArticles = GetSortArticles(sortLocale); + + var localGenreIds = rules.Enabled ? ParseLocalGenreIds(controls.GenreIds) : []; + if (rules.Enabled || localGenreIds.Length > 0) + _refreshService.EnqueueOnTheFlyMissing(ids); + + var factsByItem = (rules.Enabled || localGenreIds.Length > 0) + ? _store.GetFactsForItems(ids) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (localGenreIds.Length > 0) + { + FilterRootByGenres(root, factsByItem, localGenreIds); + items = CollectItemObjects(root).ToArray(); + ids = items + .Select(TryGetItemId) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + responseItemIds = ids; + } + + if (rules.Enabled) + { + var langs = GetNeededLanguages(rules); + var translations = _store.GetTranslations(ids, langs); + var assets = _store.GetAssets(ids, langs); + var movieGenreNames = _store.GetGenreNames("movie", controls.ClientLocale); + var tvGenreNames = _store.GetGenreNames("tv", controls.ClientLocale); + foreach (var item in items) + { + var itemId = TryGetItemId(item); + if (itemId is null || !factsByItem.TryGetValue(itemId, out var facts)) + continue; + + translations.TryGetValue(itemId, out var byLang); + var classificationFacts = GetClassificationFacts(rules, facts); + var category = PickCategory(rules, classificationFacts); + + var title = ResolveField("title", GetActionList(rules, category, "title"), facts, byLang); + if (title.Change) + item["Name"] = title.Value ?? string.Empty; + + var overview = ResolveField("overview", GetActionList(rules, category, "overview"), facts, byLang); + if (overview.Change) + { + if (string.IsNullOrWhiteSpace(overview.Value)) + item.Remove("Overview"); + else + item["Overview"] = overview.Value; + } + + var tagline = ResolveField("tagline", GetActionList(rules, category, "tagline"), facts, byLang); + if (tagline.Change) + item["Taglines"] = string.IsNullOrWhiteSpace(tagline.Value) ? new JsonArray() : new JsonArray(tagline.Value); + + assets.TryGetValue(itemId, out var assetsByKind); + ApplyImageAsset(item, "Primary", ResolveAsset("poster", GetActionList(rules, category, "poster"), assetsByKind)); + ApplyImageAsset(item, "Logo", ResolveAsset("logo", GetActionList(rules, category, "logo"), assetsByKind)); + ApplyImageAsset(item, "Banner", ResolveAsset("banner", GetActionList(rules, category, "banner"), assetsByKind)); + ApplyImageAsset(item, "Thumb", ResolveAsset("thumb", GetActionList(rules, category, "thumb"), assetsByKind)); + ApplyBackdropAsset(item, ResolveAsset("backdrop", GetActionList(rules, category, "backdrop"), assetsByKind)); + ApplyGenres(item, facts, movieGenreNames, tvGenreNames); + } + } + + ItemsProxySorting.Apply(root, controls, sortCulture, sortArticles); + return root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + } + + private static string[] GetNeededLanguages(UserRulesDocument rules) + { + var langs = new List(); + foreach (var category in rules.Categories ?? []) + { + foreach (var action in GetAllActionTokens(category.FieldActionLists)) + AddLanguageAction(langs, action); + foreach (var action in (category.FieldActions ?? []).Values) + AddLanguageAction(langs, action); + } + + foreach (var action in GetAllActionTokens(rules.FallbackFieldActions)) + AddLanguageAction(langs, action); + + return langs.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private static IEnumerable GetAllActionTokens(IReadOnlyDictionary? actions) + => actions is null ? [] : actions.Values.SelectMany(v => v ?? []); + + private static void AddLanguageAction(List langs, string action) + { + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + { + langs.Add("Original"); + return; + } + + const string prefix = "Language:"; + if (action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + langs.Add(action[prefix.Length..]); + } + + private static UserCategoryRule? PickCategory(UserRulesDocument rules, FactsData facts) + => PickCategoryMatch(rules, facts).Effective; + + private static CategoryMatch PickCategoryMatch(UserRulesDocument rules, FactsData facts) + { + foreach (var category in rules.Categories ?? []) + { + if (CategoryRuleEvaluator.Matches(category, facts)) + { + var effective = (rules.Categories ?? []) + .FirstOrDefault(first => SameCategoryLabel(first.Label, category.Label)) + ?? category; + return new CategoryMatch(category, effective, !ReferenceEquals(category, effective)); + } + } + + return new CategoryMatch(null, null, false); + } + + private FactsData GetClassificationFacts(UserRulesDocument rules, FactsData facts) + { + if (rules.TrustTmdbCollections || + !facts.Kind.Equals("collection", StringComparison.OrdinalIgnoreCase) || + !Guid.TryParseExact(facts.ItemId, "N", out var itemGuid)) + { + return facts; + } + + var item = _libraryManager.GetItemById(itemGuid); + var children = (item as Folder)?.GetLinkedChildren() + .Where(child => child.GetType().Name.Equals("Movie", StringComparison.OrdinalIgnoreCase)) + .ToArray() ?? []; + + var childIds = children + .Select(child => TranslationStore.ToItemId32(child.Id)) + .ToArray(); + if (childIds.Length == 0) + return facts; + + var childFacts = _store.GetFactsForItems(childIds).Values + .Where(child => child.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (childFacts.Length == 0) + return facts; + + return facts with + { + OriginalLanguage = SharedSingle(childFacts.Select(child => child.OriginalLanguage)), + OriginalLanguageAll = string.Join(",", childFacts + .Select(child => child.OriginalLanguage) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase)), + OriginCountriesJson = JsonSerializer.Serialize(UnionJsonArrays(childFacts.Select(child => child.OriginCountriesJson))), + ProductionCountriesJson = JsonSerializer.Serialize(UnionJsonArrays(childFacts.Select(child => child.ProductionCountriesJson))), + SpokenLanguagesJson = JsonSerializer.Serialize(UnionJsonArrays(childFacts.Select(child => child.SpokenLanguagesJson))), + AudioTrackLanguage = SharedSingle(childFacts.Select(child => child.AudioTrackLanguage)), + GenreTmdbIdsJson = JsonSerializer.Serialize(UnionJsonArrays(childFacts.Select(child => child.GenreTmdbIdsJson))) + }; + } + + private static string SharedSingle(IEnumerable values) + { + var unique = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return unique.Length == 1 ? unique[0] : string.Empty; + } + + private static string[] UnionJsonArrays(IEnumerable arrays) + => arrays + .SelectMany(CategoryRuleEvaluator.ParseJsonStringArray) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool SameCategoryLabel(string? left, string? right) + => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.Ordinal); + + private readonly record struct ResolvedText(bool Change, string? Value); + + private readonly record struct ResolvedAsset(bool Change, string? Value); + + private sealed record DebugResolutionAttempt( + string Action, + string Source, + string LookupKey, + bool HasValue, + bool Chosen, + string? Value, + string Reason); + + private sealed record DebugTextResolution( + string Field, + string[] Actions, + DebugResolutionAttempt[] Attempts, + ResolvedText Result, + string SelectedAction, + string Reason); + + private sealed record DebugAssetResolution( + string Kind, + string[] Actions, + DebugResolutionAttempt[] Attempts, + ResolvedAsset Result, + string SelectedAction, + string Reason); + + private readonly record struct CategoryMatch( + UserCategoryRule? Matched, + UserCategoryRule? Effective, + bool DuplicateLabelResolved); + + private static ResolvedText ResolveField( + string field, + IReadOnlyList actions, + FactsData facts, + Dictionary>? byLang) + { + foreach (var action in actions) + { + if (action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)) + return new ResolvedText(false, null); + if (field.Equals("title", StringComparison.OrdinalIgnoreCase) && + action.Equals("Original", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(facts.OriginalTitle)) + return new ResolvedText(true, facts.OriginalTitle); + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + { + var originalValue = GetTranslatedField(byLang, "Original", field); + if (!string.IsNullOrWhiteSpace(originalValue)) + return new ResolvedText(true, originalValue); + continue; + } + + const string prefix = "Language:"; + if (!action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + + var value = GetTranslatedField(byLang, action[prefix.Length..], field); + if (!string.IsNullOrWhiteSpace(value)) + return new ResolvedText(true, value); + } + + return field.Equals("title", StringComparison.OrdinalIgnoreCase) + ? new ResolvedText(false, null) + : new ResolvedText(true, string.Empty); + } + + private static string? GetTranslatedField( + Dictionary>? byLang, + string lang, + string field) + => byLang is not null && + byLang.TryGetValue(lang, out var byField) && + byField.TryGetValue(field, out var value) + ? value + : null; + + private static ResolvedAsset ResolveAsset( + string kind, + IReadOnlyList actions, + Dictionary>? byKind) + { + foreach (var action in actions) + { + if (action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)) + return new ResolvedAsset(false, null); + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + { + var originalValue = GetAsset(byKind, kind, "Original"); + if (!string.IsNullOrWhiteSpace(originalValue)) + return new ResolvedAsset(true, originalValue); + continue; + } + + const string prefix = "Language:"; + if (!action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + + var value = GetAsset(byKind, kind, action[prefix.Length..]); + if (!string.IsNullOrWhiteSpace(value)) + return new ResolvedAsset(true, value); + } + + return kind.Equals("poster", StringComparison.OrdinalIgnoreCase) + ? new ResolvedAsset(false, null) + : new ResolvedAsset(true, string.Empty); + } + + private static string? GetAsset( + Dictionary>? byKind, + string kind, + string lang) + => byKind is not null && + byKind.TryGetValue(kind, out var byLang) && + byLang.TryGetValue(lang, out var value) + ? value + : null; + + private static void ApplyImageAsset(JsonObject item, string imageType, ResolvedAsset asset) + { + if (!asset.Change) + return; + + var tags = item["ImageTags"] as JsonObject; + if (tags is null) + { + tags = []; + item["ImageTags"] = tags; + } + + if (string.IsNullOrWhiteSpace(asset.Value)) + tags.Remove(imageType); + else + tags[imageType] = ToImageTag(asset.Value); + } + + private static void ApplyBackdropAsset(JsonObject item, ResolvedAsset asset) + { + if (!asset.Change) + return; + + item["BackdropImageTags"] = string.IsNullOrWhiteSpace(asset.Value) + ? new JsonArray() + : new JsonArray(ToImageTag(asset.Value)); + } + + private static string ToImageTag(string url) + => "ml:" + Uri.EscapeDataString(url); + + private bool TryBuildGenresResponse(string media, string locale, out string body) + { + var rows = _store.GetGenres(media); + if (rows.Length == 0) + { + body = string.Empty; + return false; + } + + var culture = GetSortCulture(ResolveSortLocale(locale, locale)); + var comparer = StringComparer.Create(culture, true); + var items = rows + .GroupBy(r => r.TmdbId) + .Select(g => new { Id = g.Key, Name = PickGenreName(g, locale) }) + .Where(g => !string.IsNullOrWhiteSpace(g.Name)) + .OrderBy(g => g.Name, comparer) + .Select(g => new JsonObject + { + ["Name"] = g.Name, + ["Id"] = "tmdb-" + g.Id.ToString(CultureInfo.InvariantCulture), + ["Type"] = "Genre", + ["ChildCount"] = 0, + ["BackdropImageTags"] = new JsonArray(), + ["LocationType"] = "Virtual", + ["MediaType"] = "Unknown", + ["TrailerCount"] = 0, + ["MovieCount"] = media.Equals("movie", StringComparison.OrdinalIgnoreCase) ? 1 : 0, + ["SeriesCount"] = media.Equals("tv", StringComparison.OrdinalIgnoreCase) ? 1 : 0, + ["ProgramCount"] = 0, + ["EpisodeCount"] = 0, + ["SongCount"] = 0, + ["AlbumCount"] = 0, + ["ArtistCount"] = 0 + }) + .ToArray(); + + if (items.Length == 0) + { + body = string.Empty; + return false; + } + + var array = new JsonArray(); + foreach (var item in items) + array.Add(item); + body = array.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + return true; + } + + private static void ApplyGenres( + JsonObject item, + FactsData facts, + IReadOnlyDictionary movieGenreNames, + IReadOnlyDictionary tvGenreNames) + { + var media = GenreMediaForFacts(facts); + if (media.Length == 0) + return; + + var namesById = media == "movie" ? movieGenreNames : tvGenreNames; + var genreIds = ParseJsonIntArray(facts.GenreTmdbIdsJson); + var genres = genreIds + .Select(id => new { Id = id, Name = namesById.TryGetValue(id, out var name) ? name : string.Empty }) + .Where(g => !string.IsNullOrWhiteSpace(g.Name)) + .ToArray(); + if (genres.Length == 0) + return; + + item["Genres"] = new JsonArray(genres.Select(g => JsonValue.Create(g.Name)).ToArray()); + var genreItems = new JsonArray(); + foreach (var genre in genres) + { + genreItems.Add(new JsonObject + { + ["Name"] = genre.Name, + ["Id"] = "tmdb-" + genre.Id.ToString(CultureInfo.InvariantCulture) + }); + } + + item["GenreItems"] = genreItems; + } + + private static string PickGenreName(IEnumerable rows, string locale) + { + var candidates = rows.ToArray(); + var requested = (locale ?? string.Empty).Trim(); + var requestedBase = requested.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + return candidates.FirstOrDefault(r => r.Lang.Equals(requested, StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault(r => !string.IsNullOrWhiteSpace(requestedBase) && r.Lang.Equals(requestedBase, StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault(r => r.Lang.Equals("en-US", StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault(r => r.Lang.Equals("en", StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault().Name; + } + + private static void FilterRootByGenres(JsonNode root, IReadOnlyDictionary factsByItem, int[] genreIds) + { + var wanted = genreIds.ToHashSet(); + bool Matches(JsonObject item) + { + var itemId = TryGetItemId(item); + if (itemId is null || !factsByItem.TryGetValue(itemId, out var facts)) + return false; + + return ParseJsonIntArray(facts.GenreTmdbIdsJson).Any(wanted.Contains); + } + + if (root is JsonObject obj && obj["Items"] is JsonArray items) + { + var kept = items.OfType().Where(Matches).ToArray(); + items.Clear(); + foreach (var item in kept) + items.Add(item); + obj["TotalRecordCount"] = kept.Length; + obj["StartIndex"] = 0; + return; + } + + if (root is JsonArray array) + { + var kept = array.OfType().Where(Matches).ToArray(); + array.Clear(); + foreach (var item in kept) + array.Add(item); + } + } + + private static int[] ParseLocalGenreIds(string raw) + => (raw ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(v => v.StartsWith("tmdb-", StringComparison.OrdinalIgnoreCase) ? v[5..] : string.Empty) + .Where(v => int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + .Select(v => int.Parse(v, CultureInfo.InvariantCulture)) + .Distinct() + .ToArray(); + + private static int[] ParseJsonIntArray(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return []; + + try + { + return JsonSerializer.Deserialize(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static int CountRootArrayItems(string body) + { + try + { + var node = JsonNode.Parse(body); + return node is JsonArray array ? array.Count : 0; + } + catch (JsonException) + { + return 0; + } + } + + private static IEnumerable CollectItemObjects(JsonNode root) + { + if (root is JsonArray array) + { + foreach (var item in array.OfType()) + yield return item; + yield break; + } + + if (root is JsonObject obj) + { + if (obj["Items"] is JsonArray items) + { + foreach (var item in items.OfType()) + yield return item; + yield break; + } + + yield return obj; + } + } + + private static string? TryGetItemId(JsonObject item) + { + var id = item["Id"]?.GetValue(); + if (string.IsNullOrWhiteSpace(id)) + return null; + return Guid.TryParse(id, out var guid) + ? TranslationStore.ToItemId32(guid) + : id.Length == 32 && id.All(Uri.IsHexDigit) ? id.ToLowerInvariant() : null; + } + + [HttpGet("CacheEntries")] + public async Task CacheEntries() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + return Ok(_itemsProxyCache.GetEntries()); + } + + [HttpPost("ClearCache")] + public async Task ClearCache() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + _itemsProxyCache.ClearAll(); + return Ok(new { Cleared = true }); + } + + [HttpGet("ItemsProxyRequests")] + public async Task ItemsProxyRequests() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + return Ok(_itemsProxyCache.GetRecentRequests()); + } + + [HttpGet("RefreshDiagnostics")] + public async Task RefreshDiagnostics() + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + return Ok(_refreshService.GetDiagnostics()); + } + + [HttpGet("Debug/{itemId}")] + public async Task DebugItem(string itemId) + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + if (!TryParseItemId(itemId, out var itemId32, out _)) + return BadRequest(new { Error = "InvalidItemId" }); + + return Ok(new + { + ItemId = itemId32, + Facts = _store.GetFacts(itemId32), + Translations = _store.GetTranslationsForDebug(itemId32), + Rules = GetRulesOrDefault(userId) + }); + } + + [HttpGet("Debug/{itemId}/self")] + public async Task DebugItemForSelf(string itemId) + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + return DebugResolveForUser(itemId, userId); + } + + [HttpGet("Debug/{itemId}/{userId}")] + public async Task DebugItemForUser(string itemId, string userId) + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Error = "AdminRequired" }); + + var normalizedUserId = NormalizeUserId(userId); + if (normalizedUserId is null) + return BadRequest(new { Error = "InvalidUserId" }); + + return DebugResolveForUser(itemId, normalizedUserId); + } + + [HttpGet("DebugResolve/{itemId}")] + public async Task DebugResolveItem(string itemId) + { + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(new { Error = "NotAuthenticated" }); + + return DebugResolveForUser(itemId, userId); + } + + private IActionResult DebugResolveForUser(string itemId, string userId) + { + if (!TryParseItemId(itemId, out var itemId32, out _)) + return BadRequest(new { Error = "InvalidItemId" }); + + var facts = _store.GetFacts(itemId32); + if (facts is null) + return NotFound(new { Error = "FactsMissing" }); + + var rules = GetRulesOrDefault(userId); + var configuredLanguages = Plugin.Instance?.Configuration?.Languages ?? []; + var ruleLanguages = GetNeededLanguages(rules); + var debugLanguages = configuredLanguages + .Concat(ruleLanguages) + .Append("Original") + .Where(lang => !string.IsNullOrWhiteSpace(lang)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var translations = _store.GetTranslations([itemId32], debugLanguages); + var assets = _store.GetAssets([itemId32], debugLanguages); + translations.TryGetValue(itemId32, out var byLang); + assets.TryGetValue(itemId32, out var byKind); + var classificationFacts = GetClassificationFacts(rules, facts); + var categoryTraces = (rules.Categories ?? []) + .Select(category => CategoryRuleEvaluator.Trace(category, classificationFacts)) + .ToArray(); + var categoryMatch = PickCategoryMatch(rules, classificationFacts); + var category = categoryMatch.Effective; + var clientLocale = Request.Query.TryGetValue("mlLocale", out var mlLocale) ? mlLocale.ToString() : string.Empty; + var effectiveSortLocale = ResolveSortLocale(rules.SortLocale, clientLocale); + var genreLocale = string.IsNullOrWhiteSpace(clientLocale) ? effectiveSortLocale : clientLocale; + + string[] fields = ["title", "overview", "tagline", "poster", "logo", "banner", "thumb", "backdrop"]; + var actionLists = fields.ToDictionary( + field => field, + field => GetActionList(rules, category, field).ToArray(), + StringComparer.OrdinalIgnoreCase); + + var title = TraceResolveField("title", actionLists["title"], facts, byLang); + var overview = TraceResolveField("overview", actionLists["overview"], facts, byLang); + var tagline = TraceResolveField("tagline", actionLists["tagline"], facts, byLang); + var poster = TraceResolveAsset("poster", actionLists["poster"], byKind); + var logo = TraceResolveAsset("logo", actionLists["logo"], byKind); + var banner = TraceResolveAsset("banner", actionLists["banner"], byKind); + var thumb = TraceResolveAsset("thumb", actionLists["thumb"], byKind); + var backdrop = TraceResolveAsset("backdrop", actionLists["backdrop"], byKind); + var genres = ResolveGenresForDebug(facts, genreLocale); + + return Ok(new + { + UserId = userId, + ItemId = itemId32, + RulesEnabled = rules.Enabled, + SortLocale = rules.SortLocale, + ClientLocale = clientLocale, + EffectiveSortLocale = effectiveSortLocale, + GenreLocale = genreLocale, + ConfiguredLanguages = configuredLanguages, + RuleLanguages = ruleLanguages, + DebugLanguages = debugLanguages, + MatchedCategory = categoryMatch.Matched ?? new { Id = "fallback", Label = "Fallback" } as object, + EffectiveCategory = category ?? new { Id = "fallback", Label = "Fallback" } as object, + DuplicateCategoryLabelResolved = categoryMatch.DuplicateLabelResolved, + CategoryEvaluation = new + { + UsesCollectionAggregateFacts = !rules.TrustTmdbCollections && + facts.Kind.Equals("collection", StringComparison.OrdinalIgnoreCase), + CategoryTraces = categoryTraces, + FirstMatchedTrace = categoryTraces.FirstOrDefault(trace => trace.Result) + }, + FieldActions = actionLists, + FieldResolution = new + { + Title = title, + Overview = overview, + Tagline = tagline, + Poster = poster, + Logo = logo, + Banner = banner, + Thumb = thumb, + Backdrop = backdrop + }, + MissingData = BuildMissingDataReport(configuredLanguages, byLang, byKind), + Facts = facts, + ClassificationFacts = classificationFacts, + ResolvedText = new + { + Title = title.Result, + Overview = overview.Result, + Tagline = tagline.Result + }, + ResolvedAssets = new + { + Poster = poster.Result, + Logo = logo.Result, + Banner = banner.Result, + Thumb = thumb.Result, + Backdrop = backdrop.Result + }, + ResolvedGenres = genres, + FinalOutput = new + { + Name = DebugTextOutput(title.Result), + Overview = DebugTextOutput(overview.Result), + Tagline = DebugTextOutput(tagline.Result), + ImageTags = new + { + Primary = DebugAssetOutput(poster.Result), + Logo = DebugAssetOutput(logo.Result), + Banner = DebugAssetOutput(banner.Result), + Thumb = DebugAssetOutput(thumb.Result) + }, + BackdropImageTags = DebugAssetOutput(backdrop.Result), + Genres = genres + }, + TranslationLanguages = byLang?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToArray() ?? [], + AssetKinds = byKind?.ToDictionary( + kind => kind.Key, + kind => kind.Value.Keys.OrderBy(lang => lang, StringComparer.OrdinalIgnoreCase).ToArray(), + StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase) + }); + } + + private static DebugTextResolution TraceResolveField( + string field, + IReadOnlyList actions, + FactsData facts, + Dictionary>? byLang) + { + var attempts = new List(); + foreach (var action in actions) + { + if (action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)) + { + var result = new ResolvedText(false, null); + attempts.Add(new DebugResolutionAttempt(action, "Jellyfin", string.Empty, true, true, null, "Use Jellyfin value")); + return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, "Jellyfin", "Jellyfin action reached"); + } + + if (field.Equals("title", StringComparison.OrdinalIgnoreCase) && + action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + { + var hasOriginalTitle = !string.IsNullOrWhiteSpace(facts.OriginalTitle); + attempts.Add(new DebugResolutionAttempt(action, "OriginalTitle", "facts.original_title", hasOriginalTitle, hasOriginalTitle, facts.OriginalTitle, hasOriginalTitle ? "Original title found" : "Original title missing")); + if (hasOriginalTitle) + { + var result = new ResolvedText(true, facts.OriginalTitle); + return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original title found"); + } + + continue; + } + + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + { + var originalValue = GetTranslatedField(byLang, "Original", field); + var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue); + attempts.Add(new DebugResolutionAttempt(action, "Translation", "Original/" + field, hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language translation found" : "Original-language translation missing")); + if (hasOriginalValue) + { + var result = new ResolvedText(true, originalValue); + return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original-language translation found"); + } + + continue; + } + + const string prefix = "Language:"; + if (!action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token")); + continue; + } + + var lang = action[prefix.Length..]; + var value = GetTranslatedField(byLang, lang, field); + var hasValue = !string.IsNullOrWhiteSpace(value); + attempts.Add(new DebugResolutionAttempt(action, "Translation", lang + "/" + field, hasValue, hasValue, value, hasValue ? "Translation found" : "Translation missing or empty")); + if (hasValue) + { + var result = new ResolvedText(true, value); + return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Translation found"); + } + } + + var fallback = field.Equals("title", StringComparison.OrdinalIgnoreCase) + ? new ResolvedText(false, null) + : new ResolvedText(true, string.Empty); + var reason = field.Equals("title", StringComparison.OrdinalIgnoreCase) + ? "No action had data; title falls back to Jellyfin" + : "No action had data; field is cleared"; + return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), fallback, field.Equals("title", StringComparison.OrdinalIgnoreCase) ? "Jellyfin" : "Clear", reason); + } + + private static DebugAssetResolution TraceResolveAsset( + string kind, + IReadOnlyList actions, + Dictionary>? byKind) + { + var attempts = new List(); + foreach (var action in actions) + { + if (action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)) + { + var result = new ResolvedAsset(false, null); + attempts.Add(new DebugResolutionAttempt(action, "Jellyfin", string.Empty, true, true, null, "Use Jellyfin image")); + return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, "Jellyfin", "Jellyfin action reached"); + } + + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + { + var originalValue = GetAsset(byKind, kind, "Original"); + var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue); + attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/Original", hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language asset found" : "Original-language asset missing")); + if (hasOriginalValue) + { + var result = new ResolvedAsset(true, originalValue); + return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Original-language asset found"); + } + + continue; + } + + const string prefix = "Language:"; + if (!action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token")); + continue; + } + + var lang = action[prefix.Length..]; + var value = GetAsset(byKind, kind, lang); + var hasValue = !string.IsNullOrWhiteSpace(value); + attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/" + lang, hasValue, hasValue, value, hasValue ? "Asset found" : "Asset missing")); + if (hasValue) + { + var result = new ResolvedAsset(true, value); + return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Asset found"); + } + } + + var fallback = kind.Equals("poster", StringComparison.OrdinalIgnoreCase) + ? new ResolvedAsset(false, null) + : new ResolvedAsset(true, string.Empty); + var reason = kind.Equals("poster", StringComparison.OrdinalIgnoreCase) + ? "No action had data; poster falls back to Jellyfin" + : "No action had data; image is cleared"; + return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), fallback, kind.Equals("poster", StringComparison.OrdinalIgnoreCase) ? "Jellyfin" : "Clear", reason); + } + + private static object[] BuildMissingDataReport( + IEnumerable configuredLanguages, + Dictionary>? byLang, + Dictionary>? byKind) + { + string[] textFields = ["title", "overview", "tagline"]; + string[] assetKinds = ["poster", "logo", "banner", "thumb", "backdrop"]; + return configuredLanguages + .Where(lang => !string.IsNullOrWhiteSpace(lang)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(lang => lang, StringComparer.OrdinalIgnoreCase) + .Select(lang => + { + var missingText = textFields + .Where(field => string.IsNullOrWhiteSpace(GetTranslatedField(byLang, lang, field))) + .ToArray(); + var missingAssets = assetKinds + .Where(kind => string.IsNullOrWhiteSpace(GetAsset(byKind, kind, lang))) + .ToArray(); + return new + { + Language = lang, + MissingTextFields = missingText, + MissingAssetKinds = missingAssets, + Complete = missingText.Length == 0 && missingAssets.Length == 0 + } as object; + }) + .ToArray(); + } + + private static object DebugTextOutput(ResolvedText text) + => new + { + Source = text.Change ? "Multilang" : "Jellyfin", + Value = text.Value, + ClearsValue = text.Change && string.IsNullOrWhiteSpace(text.Value) + }; + + private static object DebugAssetOutput(ResolvedAsset asset) + => new + { + Source = asset.Change ? "Multilang" : "Jellyfin", + Value = asset.Change && !string.IsNullOrWhiteSpace(asset.Value) ? ToImageTag(asset.Value) : asset.Value, + ClearsValue = asset.Change && string.IsNullOrWhiteSpace(asset.Value) + }; + + private object[] ResolveGenresForDebug(FactsData facts, string locale) + { + var media = GenreMediaForFacts(facts); + if (media.Length == 0) + return []; + + var namesById = _store.GetGenreNames(media, locale); + return ParseJsonIntArray(facts.GenreTmdbIdsJson) + .Select(id => + { + var hasName = namesById.TryGetValue(id, out var name); + return new + { + Id = "tmdb-" + id.ToString(CultureInfo.InvariantCulture), + TmdbId = id, + Media = media, + Name = name ?? string.Empty, + HasLocalizedName = hasName + } as object; + }) + .ToArray(); + } + + private static string GenreMediaForFacts(FactsData facts) + { + if (facts.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase) || + facts.Kind.Equals("collection", StringComparison.OrdinalIgnoreCase)) + return "movie"; + + if (facts.Kind.Equals("tv", StringComparison.OrdinalIgnoreCase) || + facts.Kind.Equals("tvseason", StringComparison.OrdinalIgnoreCase) || + facts.Kind.Equals("tvepisode", StringComparison.OrdinalIgnoreCase)) + return "tv"; + + return string.Empty; + } + + [HttpPost("TestProvider")] + public async Task TestProvider([FromBody] TestProviderRequest request, CancellationToken cancellationToken) + { + if (!await IsRequesterAdminAsync().ConfigureAwait(false)) + return Unauthorized(new { Ok = false, Error = "AdminRequired" }); + + var provider = (request.Provider ?? string.Empty).Trim().ToLowerInvariant(); + var apiKey = (request.ApiKey ?? string.Empty).Trim(); + if (apiKey.Length == 0) + return Ok(new { Ok = false, Error = "MissingApiKey" }); + + var http = _httpClientFactory.CreateClient(); + if (provider == "tmdb") + { + using var response = await http.GetAsync($"https://api.themoviedb.org/3/configuration?api_key={Uri.EscapeDataString(apiKey)}", cancellationToken).ConfigureAwait(false); + return Ok(new { Ok = response.IsSuccessStatusCode, Status = (int)response.StatusCode }); + } + + if (provider == "fanart") + return Ok(new { Ok = await _fanartClient.TestApiKeyAsync(apiKey, cancellationToken).ConfigureAwait(false) }); + + return BadRequest(new { Ok = false, Error = "UnknownProvider" }); + } + + private UserRulesDocument GetRulesOrDefault(string userId) + { + var existing = _store.GetUserRules(userId); + if (existing is not null) + { + existing.Categories ??= []; + existing.FallbackLanguages ??= []; + existing.FallbackFieldActions = NormalizeActionLists(existing.FallbackFieldActions, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase)); + existing.Categories = NormalizeCategories(existing.Categories, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase)); + existing.FallbackLanguages = GetNeededLanguages(existing); + return existing; + } + + return new UserRulesDocument + { + Enabled = false, + SortLocale = "Auto", + TrustTmdbCollections = true, + FallbackLanguages = [], + FallbackFieldActions = DefaultActionLists(), + Categories = [], + Rules = [] + }; + } + + private static UserCategoryRule[] NormalizeCategories(IEnumerable? categories, IReadOnlySet allowedLanguages) + => (categories ?? []) + .Select(category => new UserCategoryRule + { + Id = string.IsNullOrWhiteSpace(category.Id) ? Guid.NewGuid().ToString("N") : category.Id.Trim(), + Label = string.IsNullOrWhiteSpace(category.Label) ? "Category" : category.Label.Trim(), + CriteriaText = category.CriteriaText?.Trim() ?? string.Empty, + Requirements = NormalizeRequirements(category.Requirements), + MatchAllConditions = category.MatchAllConditions, + Scopes = (category.Scopes ?? []) + .Where(scope => scope is "M" or "S" or "C") + .Distinct(StringComparer.OrdinalIgnoreCase) + .DefaultIfEmpty("M") + .ToArray(), + FieldActions = NormalizeFieldActions(category.FieldActions, allowedLanguages), + FieldActionLists = NormalizeActionLists(category.FieldActionLists, allowedLanguages, category.FieldActions) + }) + .ToArray(); + + private static UserCategoryRequirement[] NormalizeRequirements(IEnumerable? requirements) + => (requirements ?? []) + .Select(requirement => + { + var fields = NormalizeRequirementFields(requirement.Fields, requirement.Field); + return new UserCategoryRequirement + { + Fields = fields, + Field = fields.FirstOrDefault() ?? string.Empty, + UseFieldOr = requirement.UseFieldOr, + Relation = NormalizeRequirementRelation(requirement.Relation), + UseOr = requirement.UseOr, + Values = (requirement.Values ?? []) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + }) + .Where(requirement => + requirement.Fields.Length > 0 && + requirement.Relation.Length > 0 && + requirement.Values.Length > 0) + .ToArray(); + + private static string[] NormalizeRequirementFields(IEnumerable? fields, string? legacyField) + { + var requested = (fields ?? []) + .Append(legacyField ?? string.Empty) + .Select(NormalizeRequirementField) + .Where(field => field.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var result = new List(); + string? kind = null; + foreach (var field in RequirementFieldOrder) + { + if (!requested.Contains(field)) + continue; + + var currentKind = IsCountryRequirementField(field) ? "country" : "language"; + kind ??= currentKind; + if (currentKind == kind) + result.Add(field); + } + + return result.ToArray(); + } + + private static string NormalizeRequirementField(string? field) + { + var normalized = (field ?? string.Empty).Trim().ToLowerInvariant(); + return normalized is "original_language" or "spoken_languages" or "audio_language" or "origin_countries" or "production_countries" + ? normalized + : string.Empty; + } + + private static bool IsCountryRequirementField(string field) + => field.Equals("origin_countries", StringComparison.OrdinalIgnoreCase) || + field.Equals("production_countries", StringComparison.OrdinalIgnoreCase); + + private static string NormalizeRequirementRelation(string? relation) + { + var normalized = (relation ?? string.Empty).Trim().ToLowerInvariant(); + return normalized is "is" or "is_not" or "contains" or "not_contains" + ? normalized + : string.Empty; + } + + private static Dictionary NormalizeActionLists( + IReadOnlyDictionary? actions, + IReadOnlySet allowedLanguages, + IReadOnlyDictionary? legacyActions = null) + { + if (legacyActions is not null && LooksLikeDefaultActionLists(actions) && HasMeaningfulLegacyActions(legacyActions)) + actions = null; + + var result = DefaultActionLists(); + foreach (var field in result.Keys.ToArray()) + { + var raw = actions is not null && actions.TryGetValue(field, out var configured) + ? configured + : LegacyActionToList(field, legacyActions); + result[field] = NormalizeActionList(field, raw, allowedLanguages); + } + + return result; + } + + private static bool LooksLikeDefaultActionLists(IReadOnlyDictionary? actions) + => actions is null || + DefaultActionLists().Keys.All(field => + actions.TryGetValue(field, out var list) && + list.Length == 1 && + list[0].Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)); + + private static bool HasMeaningfulLegacyActions(IReadOnlyDictionary legacyActions) + => legacyActions.Values.Any(action => + !string.IsNullOrWhiteSpace(action) && + !action.Equals("Fallback", StringComparison.OrdinalIgnoreCase) && + !action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)); + + private static string[] LegacyActionToList(string field, IReadOnlyDictionary? legacyActions) + { + if (legacyActions is null || !legacyActions.TryGetValue(field, out var action)) + return ["Jellyfin"]; + if (string.IsNullOrWhiteSpace(action) || action.Equals("Fallback", StringComparison.OrdinalIgnoreCase)) + return ["Jellyfin"]; + return [action]; + } + + private static Dictionary NormalizeFieldActions( + IReadOnlyDictionary? actions, + IReadOnlySet allowedLanguages) + { + var result = DefaultFieldActions(); + if (actions is null) + return result; + + foreach (var field in result.Keys.ToArray()) + { + if (!actions.TryGetValue(field, out var raw)) + continue; + + result[field] = NormalizeAction(field, raw, allowedLanguages); + } + + return result; + } + + private static string NormalizeAction(string field, string? raw, IReadOnlySet allowedLanguages) + { + var action = (raw ?? string.Empty).Trim(); + if (action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase) || + action.Equals("Fallback", StringComparison.OrdinalIgnoreCase)) + return action; + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + return "Original"; + if ((field is "overview" or "tagline" or "logo") && + action.Equals("Clear", StringComparison.OrdinalIgnoreCase)) + return "Clear"; + const string prefix = "Language:"; + if (action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + var lang = action[prefix.Length..].Trim(); + var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(canonical)) + return prefix + canonical; + } + + return "Fallback"; + } + + private static string[] NormalizeActionList(string field, IEnumerable? raw, IReadOnlySet allowedLanguages) + { + var result = new List(); + foreach (var action in raw ?? []) + { + var normalized = NormalizeActionToken(field, action, allowedLanguages); + if (normalized.Length == 0) + continue; + if (result.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + continue; + result.Add(normalized); + } + + if (field.Equals("title", StringComparison.OrdinalIgnoreCase) || + field.Equals("poster", StringComparison.OrdinalIgnoreCase)) + { + if (!result.Contains("Jellyfin", StringComparer.OrdinalIgnoreCase)) + result.Add("Jellyfin"); + } + + return result.ToArray(); + } + + private static string NormalizeActionToken(string field, string? raw, IReadOnlySet allowedLanguages) + { + var action = (raw ?? string.Empty).Trim(); + if (action.Equals("J", StringComparison.OrdinalIgnoreCase)) + return "Jellyfin"; + if (action.Equals("O", StringComparison.OrdinalIgnoreCase)) + return "Original"; + if (action.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)) + return "Jellyfin"; + if (action.Equals("Original", StringComparison.OrdinalIgnoreCase)) + return "Original"; + + const string prefix = "Language:"; + var lang = action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? action[prefix.Length..].Trim() + : action; + var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase)); + return string.IsNullOrWhiteSpace(canonical) ? string.Empty : prefix + canonical; + } + + private static Dictionary DefaultFieldActions() + => new(StringComparer.OrdinalIgnoreCase) + { + ["title"] = "Fallback", + ["overview"] = "Fallback", + ["tagline"] = "Fallback", + ["poster"] = "Fallback", + ["logo"] = "Fallback", + ["banner"] = "Fallback", + ["thumb"] = "Fallback", + ["backdrop"] = "Fallback" + }; + + private static Dictionary DefaultActionLists() + => new(StringComparer.OrdinalIgnoreCase) + { + ["title"] = ["Jellyfin"], + ["overview"] = ["Jellyfin"], + ["tagline"] = ["Jellyfin"], + ["poster"] = ["Jellyfin"], + ["logo"] = ["Jellyfin"], + ["banner"] = ["Jellyfin"], + ["thumb"] = ["Jellyfin"], + ["backdrop"] = ["Jellyfin"] + }; + + private static IReadOnlyList GetActionList(UserRulesDocument rules, UserCategoryRule? category, string field) + { + var actions = category is null ? rules.FallbackFieldActions : category.FieldActionLists; + if (actions is not null && actions.TryGetValue(field, out var list)) + return list; + + if (category is not null && category.FieldActions is not null) + return LegacyActionToList(field, category.FieldActions); + + return ["Jellyfin"]; + } + + private sealed record ProxyRequest( + Uri Upstream, + ItemsProxyControls Controls, + string CacheKey, + string NormalizedUrlForCache, + bool IsGenresRequest, + string GenreMedia); + + private ProxyRequest? BuildProxyRequest(string rawUrl, string token, string userId, bool multilangEnabled) + { + var baseUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + var baseAddress = new Uri(baseUri); + var uri = new Uri(baseAddress, rawUrl.StartsWith('/') ? rawUrl : "/" + rawUrl); + if ((rawUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + rawUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) && + Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute)) + { + if (!absolute.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !absolute.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return null; + + if (!absolute.Host.Equals(baseAddress.Host, StringComparison.OrdinalIgnoreCase) || + absolute.Port != baseAddress.Port) + return null; + + uri = absolute; + } + + var path = NormalizeUpstreamPath(uri.AbsolutePath, userId); + var builder = new UriBuilder(uri); + builder.Path = path; + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(builder.Query); + var rawControls = new ItemsProxyControls( + GetQueryString(query, "SortBy"), + GetQueryString(query, "SortOrder"), + GetQueryString(query, "NameStartsWith"), + GetQueryInt(query, "StartIndex"), + GetQueryInt(query, "Limit"), + Request.Query.TryGetValue("mlLocale", out var clientLocale) ? clientLocale.ToString() : string.Empty, + GetQueryString(query, "GenreIds")); + var hasLocalGenreFilter = multilangEnabled && ParseLocalGenreIds(rawControls.GenreIds).Length > 0; + var useLocalControls = multilangEnabled && ShouldUseLocalControls(path, query, hasLocalGenreFilter); + var controls = useLocalControls + ? rawControls + : new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds); + var localCacheKey = string.Join('|', + controls.SortBy, + controls.SortOrder, + controls.NameStartsWith, + controls.StartIndex.ToString(CultureInfo.InvariantCulture), + controls.Limit.ToString(CultureInfo.InvariantCulture), + controls.ClientLocale, + controls.GenreIds); + var pairs = query + .Where(kv => !IsLocalProxyQuery(kv.Key, hasLocalGenreFilter, useLocalControls)) + .SelectMany(kv => kv.Value.Select(v => KeyValuePair.Create(kv.Key, v ?? string.Empty))) + .ToList(); + pairs.Add(KeyValuePair.Create("userId", userId)); + if (hasLocalGenreFilter) + { + pairs.Add(KeyValuePair.Create("EnableImageTypes", "Primary,Backdrop,Banner,Thumb")); + pairs.Add(KeyValuePair.Create("Fields", "Genres,GenreItems")); + } + + var cacheQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?'); + pairs.Add(KeyValuePair.Create("api_key", token)); + builder.Query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?'); + var upstream = builder.Uri; + var normalizedForCache = builder.Path + (cacheQuery.Length > 0 ? "?" + cacheQuery : string.Empty); + return new ProxyRequest( + upstream, + controls, + $"{userId}|{normalizedForCache}|{localCacheKey}", + normalizedForCache, + IsGenresPath(path), + GenreMediaFromQuery(query)); + } + + private static bool ShouldUseLocalControls( + string normalizedPath, + IReadOnlyDictionary query, + bool hasGenreFilter) + { + if (hasGenreFilter || !string.IsNullOrWhiteSpace(GetQueryString(query, "NameStartsWith"))) + return true; + + var segments = normalizedPath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => s.ToLowerInvariant()) + .ToArray(); + var itemsIndex = Array.LastIndexOf(segments, "items"); + if (itemsIndex < 0) + return false; + + return itemsIndex == segments.Length - 1; + } + + private static bool IsGenresPath(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return segments.Length > 0 && segments[^1].Equals("Genres", StringComparison.OrdinalIgnoreCase); + } + + private static string GenreMediaFromQuery(IReadOnlyDictionary query) + { + var types = GetQueryString(query, "IncludeItemTypes") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (types.Any(t => t.Equals("Series", StringComparison.OrdinalIgnoreCase) || + t.Equals("Season", StringComparison.OrdinalIgnoreCase) || + t.Equals("Episode", StringComparison.OrdinalIgnoreCase))) + { + return "tv"; + } + + return "movie"; + } + + + private static bool IsLocalProxyQuery(string key, bool hasGenreFilter, bool useLocalControls) + => key.Equals("token", StringComparison.OrdinalIgnoreCase) || + key.Equals("api_key", StringComparison.OrdinalIgnoreCase) || + key.Equals("mlLocale", StringComparison.OrdinalIgnoreCase) || + key.Equals("userId", StringComparison.OrdinalIgnoreCase) || + (useLocalControls && (key.Equals("SortBy", StringComparison.OrdinalIgnoreCase) || + key.Equals("SortOrder", StringComparison.OrdinalIgnoreCase) || + key.Equals("StartIndex", StringComparison.OrdinalIgnoreCase) || + key.Equals("Limit", StringComparison.OrdinalIgnoreCase) || + key.Equals("NameStartsWith", StringComparison.OrdinalIgnoreCase))) || + (hasGenreFilter && (key.Equals("GenreIds", StringComparison.OrdinalIgnoreCase) || + key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase) || + key.Equals("EnableImageTypes", StringComparison.OrdinalIgnoreCase) || + key.Equals("Fields", StringComparison.OrdinalIgnoreCase))); + + private static string NormalizeUpstreamPath(string path, string userId) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList(); + if (segments.Count >= 2 && + segments[0].Equals("Users", StringComparison.OrdinalIgnoreCase) && + segments[1].Length == 32 && + segments[1].All(Uri.IsHexDigit)) + { + segments[1] = userId; + return "/" + string.Join('/', segments); + } + + if (segments.Count > 0 && segments[0].Equals("Items", StringComparison.OrdinalIgnoreCase)) + return "/Users/" + userId + "/" + string.Join('/', segments); + + return path; + } + + private static string GetQueryString(IReadOnlyDictionary query, string key) + => query.TryGetValue(key, out var value) ? value.ToString() : string.Empty; + + private static int GetQueryInt(IReadOnlyDictionary query, string key) + => int.TryParse(GetQueryString(query, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : 0; + + private static string ResolveSortLocale(string? configuredLocale, string? clientLocale) + { + var configured = (configuredLocale ?? string.Empty).Trim(); + if (configured.Length > 0 && !configured.Equals("Auto", StringComparison.OrdinalIgnoreCase)) + return configured; + + return (clientLocale ?? string.Empty).Trim(); + } + + private CultureInfo GetSortCulture(string? sortLocale) + { + var locale = (sortLocale ?? string.Empty).Trim(); + if (locale.Length == 0) + return CultureInfo.InvariantCulture; + + try + { + return CultureInfo.GetCultureInfo(locale); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } + + private string[] GetSortArticles(string? sortLocale) + { + var locale = (sortLocale ?? string.Empty).Trim(); + if (locale.Length == 0) + locale = "en"; + + var configured = Plugin.Instance?.Configuration?.ArticleEntries ?? []; + var custom = configured.FirstOrDefault(e => e.Language.Equals(locale, StringComparison.OrdinalIgnoreCase)) + ?? configured.FirstOrDefault(e => locale.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase)); + if (custom is not null) + return SplitArticles(custom.Articles); + + var builtIns = _articleCatalog.GetBuiltIns(); + if (builtIns.TryGetValue(locale, out var exact)) + return exact; + + var dash = locale.IndexOf('-', StringComparison.Ordinal); + if (dash > 0 && builtIns.TryGetValue(locale[..dash], out var languageOnly)) + return languageOnly; + + return []; + } + + private static string[] SplitArticles(string articles) + => articles.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool TryGetItemsArray(JsonNode root, out JsonArray items) + { + if (root is JsonObject obj && obj["Items"] is JsonArray array) + { + items = array; + return true; + } + + if (root is JsonArray direct) + { + items = direct; + return true; + } + + items = []; + return false; + } + + private IActionResult ServeEmbedded(string resource, string contentType) + { + var content = ReadEmbedded(resource); + if (content is null) + return NotFound(); + Response.Headers["Cache-Control"] = "no-cache"; + return Content(content, contentType); + } + + private static string? ReadEmbedded(string resource) + { + var asm = Assembly.GetExecutingAssembly(); + using var stream = asm.GetManifestResourceStream(resource); + if (stream is null) + return null; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + private async Task GetUserIdAsync() + { + var info = await _authorizationContext.GetAuthorizationInfo(HttpContext).ConfigureAwait(false); + if (info is not null && info.IsAuthenticated) + { + if (info.User is not null) + return info.User.Id.ToString("N"); + if (info.UserId != Guid.Empty) + return info.UserId.ToString("N"); + } + + var token = GetTokenFromRequest(); + if (string.IsNullOrWhiteSpace(token)) + return null; + + var tokenUserId = await GetUserIdFromJellyfinApiAsync(token).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(tokenUserId)) + return tokenUserId; + + var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty; + var deviceId = info?.DeviceId ?? string.Empty; + var session = await _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint).ConfigureAwait(false); + if (session is not null) + return session.UserId.ToString("N"); + + return null; + } + + private static string? NormalizeUserId(string userId) + { + userId = (userId ?? string.Empty).Trim(); + if (Guid.TryParse(userId, out var guid)) + return guid.ToString("N"); + + return userId.Length == 32 && userId.All(Uri.IsHexDigit) + ? userId.ToLowerInvariant() + : null; + } + + private async Task GetUserIdFromJellyfinApiAsync(string token) + { + var baseUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + var usersMe = new UriBuilder(new Uri(new Uri(baseUri), "/Users/Me")); + usersMe.Query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString( + string.Empty, + "api_key", + token).TrimStart('?'); + + var http = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, usersMe.Uri); + request.Headers.TryAddWithoutValidation("X-Emby-Token", token); + request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token); + using var response = await http.SendAsync(request, HttpContext.RequestAborted).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(HttpContext.RequestAborted).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: HttpContext.RequestAborted).ConfigureAwait(false); + if (!doc.RootElement.TryGetProperty("Id", out var idProperty)) + return null; + + var raw = idProperty.GetString(); + return Guid.TryParse(raw, out var guid) + ? guid.ToString("N") + : raw?.Length == 32 && raw.All(Uri.IsHexDigit) ? raw.ToLowerInvariant() : null; + } + + private async Task IsRequesterAdminAsync() + { + var info = await _authorizationContext.GetAuthorizationInfo(HttpContext).ConfigureAwait(false); + if (info?.User is not null && TryGetIsAdmin(info.User, out var authInfoAdmin)) + return authInfoAdmin; + + var userId = await GetUserIdAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId) || !Guid.TryParseExact(userId, "N", out var guid)) + return false; + + var user = _userManager.GetUserById(guid); + if (user is null) + return false; + + if (TryGetIsAdmin(user, out var isAdmin)) + return isAdmin; + + var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty; + var dto = _userManager.GetUserDto(user, remoteEndpoint); + return dto.Policy?.IsAdministrator == true; + } + + private static bool TryGetIsAdmin(object user, out bool isAdmin) + { + isAdmin = false; + + var policy = user.GetType().GetProperty("Policy")?.GetValue(user); + var policyAdmin = policy?.GetType().GetProperty("IsAdministrator")?.GetValue(policy); + if (policyAdmin is bool isPolicyAdmin) + { + isAdmin = isPolicyAdmin; + return true; + } + + var directAdmin = user.GetType().GetProperty("IsAdministrator")?.GetValue(user); + if (directAdmin is bool isDirectAdmin) + { + isAdmin = isDirectAdmin; + return true; + } + + return false; + } + + private string? GetTokenFromRequest() + { + var req = HttpContext?.Request; + if (req is null) + return null; + + if (req.Query.TryGetValue("token", out var token) && !string.IsNullOrWhiteSpace(token)) + return token.ToString(); + if (req.Query.TryGetValue("api_key", out var apiKey) && !string.IsNullOrWhiteSpace(apiKey)) + return apiKey.ToString(); + + var headerToken = req.Headers["X-Emby-Token"].ToString(); + if (!string.IsNullOrWhiteSpace(headerToken)) + return headerToken; + headerToken = req.Headers["X-MediaBrowser-Token"].ToString(); + if (!string.IsNullOrWhiteSpace(headerToken)) + return headerToken; + + var auth = req.Headers.Authorization.ToString(); + const string marker = "Token=\""; + var idx = auth.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return null; + var start = idx + marker.Length; + var end = auth.IndexOf('"', start); + return end > start ? auth[start..end] : null; + } + + private static bool TryParseItemId(string itemId, out string itemId32, out Guid itemGuid) + { + itemId32 = string.Empty; + itemGuid = default; + if (Guid.TryParse(itemId, out var guid)) + { + itemGuid = guid; + itemId32 = TranslationStore.ToItemId32(guid); + return true; + } + + if (itemId.Length == 32 && itemId.All(Uri.IsHexDigit)) + { + itemId32 = itemId.ToLowerInvariant(); + itemGuid = Guid.ParseExact(itemId32, "N"); + return true; + } + + return false; + } +} + +public sealed record TestProviderRequest(string Provider, string ApiKey); + +public sealed class AdminConfigDto +{ + public string TmdbApiKey { get; set; } = string.Empty; + + public string FanartApiKey { get; set; } = string.Empty; + + public string[] Languages { get; set; } = []; + + public ProviderState[] Providers { get; set; } = []; + + public int WaitDaysForMissingData { get; set; } + + public int WaitDaysForExistingData { get; set; } + + public int ItemsProxyCacheThresholdMs { get; set; } + + public int ItemsProxyCacheTtlMinutes { get; set; } + + public int ItemsProxyCacheMaxMiB { get; set; } + + public SortArticleEntry[] ArticleEntries { get; set; } = []; + + public bool EnableLogging { get; set; } + + public bool VerboseLogging { get; set; } + + public static AdminConfigDto FromConfiguration(PluginConfiguration cfg) + => new() + { + TmdbApiKey = cfg.TmdbApiKey, + FanartApiKey = cfg.FanartApiKey, + Languages = cfg.Languages, + Providers = cfg.Providers, + WaitDaysForMissingData = cfg.WaitDaysForMissingData, + WaitDaysForExistingData = cfg.WaitDaysForExistingData, + ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs, + ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes, + ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB, + ArticleEntries = cfg.ArticleEntries, + EnableLogging = cfg.EnableLogging, + VerboseLogging = cfg.VerboseLogging + }; + + public PluginConfiguration ToConfiguration(ProviderCatalog providerCatalog) + => new() + { + TmdbApiKey = TmdbApiKey?.Trim() ?? string.Empty, + FanartApiKey = FanartApiKey?.Trim() ?? string.Empty, + Languages = NormalizeLanguages(Languages), + Providers = Providers + .Where(p => providerCatalog.IsKnown(p.Id)) + .Select(p => new ProviderState { Id = p.Id.Trim().ToLowerInvariant(), MetadataOrder = p.MetadataOrder, ArtworkOrder = p.ArtworkOrder }) + .ToArray(), + WaitDaysForMissingData = Math.Max(0, WaitDaysForMissingData), + WaitDaysForExistingData = Math.Max(0, WaitDaysForExistingData), + ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs), + ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes), + ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB), + ArticleEntries = ArticleEntries + .Where(e => !string.IsNullOrWhiteSpace(e.Language)) + .Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply }) + .ToArray(), + EnableLogging = EnableLogging || VerboseLogging, + VerboseLogging = VerboseLogging + }; + + private static string[] NormalizeLanguages(IEnumerable languages) + => languages.Select(l => (l ?? string.Empty).Trim()) + .Where(l => l.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs b/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..f319d3e --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Configuration/PluginConfiguration.cs @@ -0,0 +1,55 @@ +using System.Xml.Serialization; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.Multilang.Configuration; + +public sealed class PluginConfiguration : BasePluginConfiguration +{ + public string TmdbApiKey { get; set; } = string.Empty; + + public string FanartApiKey { get; set; } = string.Empty; + + public string[] Languages { get; set; } = ["en-US", "fi-FI", "sv-SE"]; + + public ProviderState[] Providers { get; set; } = + [ + new() { Id = "tmdb", MetadataOrder = 0, ArtworkOrder = 0 }, + new() { Id = "fanart", MetadataOrder = -1, ArtworkOrder = 1 } + ]; + + public int WaitDaysForMissingData { get; set; } = 7; + + public int WaitDaysForExistingData { get; set; } = 60; + + public int ItemsProxyCacheThresholdMs { get; set; } = 1000; + + public int ItemsProxyCacheTtlMinutes { get; set; } = 120; + + public int ItemsProxyCacheMaxMiB { get; set; } = 50; + + [XmlArray("SortArticles")] + [XmlArrayItem("SortArticleEntry")] + public SortArticleEntry[] ArticleEntries { get; set; } = []; + + public bool EnableLogging { get; set; } + + public bool VerboseLogging { get; set; } +} + +public sealed class ProviderState +{ + public string Id { get; set; } = string.Empty; + + public int MetadataOrder { get; set; } = -1; + + public int ArtworkOrder { get; set; } = -1; +} + +public sealed class SortArticleEntry +{ + public string Language { get; set; } = string.Empty; + + public string Articles { get; set; } = string.Empty; + + public bool? AlwaysApply { get; set; } +} diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html new file mode 100644 index 0000000..e230121 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html @@ -0,0 +1,752 @@ +
+
+ +

Multilang

+ +
+
+ Providers +
+ +
+
Provider priority order
+
+
+
Metadata
+
+
    +
    +
    +
    +
    Artwork
    +
    +
      +
      +
      +
      +
      +
      + +
      + Languages +
      + + +
      Use BCP-47 language tags. These are the languages refresh jobs fetch and user rules can select.
      +
      + +
      + Articles +
      Comma- or semicolon-separated leading articles to ignore per language.
      +
      +
      + + + +
      +
      +
      + +
      + Scanning task +
      + + +
      +
      + + +
      +
      + +
      + Refresh activity + +

      Current scan

      +
      +

      Queue

      +
      +

      Provider HTTP

      +
      +
      + +
      + Items cache +
      + + +
      Responses slower than this threshold can be stored in the in-memory ItemsProxy cache.
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +

      Stored responses

      +
      +

      Recent requests

      +
      +
      + +
      + Logging +
      + +
      +
      + +
      +
      + + + +
      + +
      + + + +
      +
      diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/debugPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/debugPage.html new file mode 100644 index 0000000..2887f14 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Configuration/debugPage.html @@ -0,0 +1,227 @@ +
      +
      + +

      Multilang Debug

      + +
      + Request +
      + + + +
      +
      +
      + +
      + Result +
      +
      Loading...
      +
      + +
      + + + +
      +
      diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/shared.css b/src/Jellyfin.Plugin.Multilang/Configuration/shared.css new file mode 100644 index 0000000..7bd263b --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Configuration/shared.css @@ -0,0 +1,813 @@ +.ml-root { + max-width: 1120px; + margin: 0 auto; + padding: 1.5rem; + color: inherit; +} + +html, +body { + min-height: 100%; + background: #101010; + color: rgba(255, 255, 255, .92); + color-scheme: dark; +} + +.ml-section { + border-top: 1px solid rgba(255, 255, 255, .16); + margin-top: 1.2rem; + padding: 1.1rem 1rem 1rem 1.35rem; +} + +.ml-section:first-of-type { + margin-top: 0; +} + +.ml-section legend { + font-size: 1.35rem; + font-weight: 700; + margin-left: -.35rem; + padding-right: .75rem; +} + +.ml-section summary { + cursor: pointer; + font-size: 1.08rem; + font-weight: 600; +} + +.ml-grid { + display: grid; + gap: .8rem; +} + +.ml-grid-2 { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.ml-row { + display: flex; + align-items: center; + gap: .75rem; + min-height: 2.6rem; +} + +.ml-row > label { + min-width: 11rem; +} + +.ml-input, +.ml-select, +.ml-textarea { + width: 100%; + box-sizing: border-box; + padding: .55rem .7rem; + border: 1px solid rgba(255, 255, 255, .22); + border-radius: 6px; + background: rgba(0, 0, 0, .18); + color: inherit; + color-scheme: dark; +} + +.ml-select { + min-height: 2.45rem; +} + +.ml-enable-row { + margin-bottom: 1rem; +} + +.ml-sort-row { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.ml-sort-row .inputLabel { + margin: 0; +} + +.ml-sort-row .ml-select { + width: auto; + min-width: max-content; +} + +.ml-translation-option { + margin-bottom: .8rem; +} + +.ml-debug-toolbar { + display: flex; + align-items: center; + gap: .75rem; + flex-wrap: wrap; +} + +.ml-debug-toolbar .ml-select { + width: auto; + min-width: 16rem; +} + +.ml-debug-output { + box-sizing: border-box; + width: 100%; + max-height: 70vh; + min-height: 18rem; + overflow: auto; + margin: 0; + padding: .8rem; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 6px; + background: rgba(0, 0, 0, .24); + color: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.ml-debug-summary { + display: flex; + flex-direction: column; + gap: .8rem; + margin-bottom: .8rem; +} + +.ml-debug-card { + display: flex; + flex-direction: column; + gap: .45rem; +} + +.ml-debug-small { + font-size: .92rem; + line-height: 1.35; +} + +.ml-debug-detail { + color: rgba(255, 255, 255, .7); + font-size: .86rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.ml-danger { + background: #7a1f1f !important; + border-color: #b04444 !important; + color: #fff !important; +} + +.ml-danger:hover { + border-color: #d06060 !important; +} + +.ml-danger[disabled], +.ml-danger[disabled]:hover { + background: #3a3a3a !important; + border-color: #555 !important; + color: rgba(255, 255, 255, .45) !important; + cursor: not-allowed !important; + opacity: .65; +} + +.ml-select option, +select.emby-select option { + background: #1f1f1f; + color: rgba(255, 255, 255, .92); +} + +.ml-select option:checked, +select.emby-select option:checked { + background: rgba(120, 170, 255, .35); + color: #fff; +} + +.ml-textarea { + min-height: 5rem; + resize: vertical; +} + +.ml-button { + min-height: 2.4rem; + border: 0; + border-radius: 6px; + padding: .5rem .9rem; + cursor: pointer; +} + +.ml-button[disabled], +.ml-input[disabled] { + opacity: .55; + cursor: not-allowed; +} + +.ml-provider, +.ml-rule { + border: 1px solid rgba(255, 255, 255, .16); + border-radius: 8px; + padding: .8rem; + background: rgba(255, 255, 255, .04); +} + +.ml-chip { + display: inline-flex; + align-items: center; + gap: .35rem; + margin: .2rem; + padding: .4rem .65rem; + border-radius: 999px; + background: rgba(120, 170, 255, .2); + border: 1px solid rgba(120, 170, 255, .35); + cursor: grab; + user-select: none; +} + +.ml-bucket { + min-height: 4.6rem; + padding: .55rem; + border: 1px dashed rgba(255, 255, 255, .28); + border-radius: 8px; +} + +.ml-bucket.ml-over { + border-color: rgba(120, 200, 255, .9); + background: rgba(120, 200, 255, .08); +} + +.ml-tabs { + display: flex; + gap: .4rem; + margin: 1rem 0; +} + +.ml-tab { + border: 1px solid rgba(255, 255, 255, .18); + background: transparent; + color: inherit; + border-radius: 6px; + padding: .45rem .75rem; + cursor: pointer; +} + +.ml-tab[aria-selected="true"] { + background: rgba(120, 170, 255, .22); +} + +.ml-status { + min-height: 1.4rem; + margin-top: .8rem; +} + +.ml-muted { + opacity: .7; +} + +.ml-error { + color: #ff9b9b; +} + +.ml-actions { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin-top: 1rem; +} + +.ml-buckets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: .8rem; +} + +.ml-bucket-col { + min-width: 0; +} + +.ml-bucket-title { + display: flex; + align-items: center; + gap: .45rem; + margin: .35rem 0; + font-weight: 600; +} + +.ml-provider-list { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: .35rem; + min-height: 4rem; + margin: 0; + padding: .45rem; + list-style: none; +} + +.ml-provider-input-row { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.ml-provider-key { + flex: 1 1 18rem; +} + +.ml-provider-source { + width: var(--ml-provider-source-width, auto); + justify-content: center; + border-color: #666; + color: rgba(255, 255, 255, .92); + background: #1f1f1f; +} + +.ml-provider-chip.ml-provider-placeholder { + border-style: dashed; + opacity: .55; +} + +.ml-provider-drag-ghost { + opacity: .85; +} + +.ml-provider-cap { + width: 1.75rem; + min-width: 1.75rem; + text-align: center; + border: 1px solid #444; + border-radius: 6px; + padding: .35rem 0; + background: #1a1a1a; + color: rgba(255, 255, 255, .92); + user-select: none; + -webkit-user-select: none; +} + +.ml-provider-cap-on { + border-color: #666; +} + +.ml-provider-cap-off { + opacity: .35; +} + +.ml-bucket-list-over { + outline: 1px solid rgba(120, 200, 255, .9); + background: rgba(120, 200, 255, .08); +} + +.ml-articles { + margin-top: .5rem; +} + +.ml-articles-list { + display: flex; + flex-direction: column; + gap: .4rem; + margin-top: .5rem; +} + +.ml-articles-row { + display: flex; + gap: .5rem; + align-items: center; + flex-wrap: wrap; +} + +.ml-articles-lang { + width: 6rem; +} + +.ml-articles-words { + flex: 1 1 18rem; +} + +.ml-articles-apply { + display: inline-flex; + align-items: center; + gap: .25rem; +} + +.ml-articles-apply input { + margin: 0; +} + +.ml-articles-actions { + display: flex; + gap: .5rem; + flex-wrap: wrap; + margin-top: .5rem; +} + +.ml-list-box { + display: flex; + flex-direction: column; + gap: .35rem; + min-height: 4.6rem; + padding: .45rem; + border: 1px dashed rgba(255, 255, 255, .28); + border-radius: 8px; +} + +.ml-list-row { + display: flex; + align-items: center; + gap: .4rem; + min-height: 2.35rem; +} + +.ml-list-label { + flex: 1 1 auto; + min-width: 6rem; +} + +.ml-list-action { + min-height: 2rem; + padding: .35rem .6rem; +} + +.ml-category-list { + display: flex; + flex-direction: column; + gap: .9rem; + margin-bottom: .75rem; +} + +.ml-classification-rule { + display: grid; + grid-template-columns: 1fr auto; + gap: .75rem; + align-items: stretch; +} + +.ml-category-card { + border: 1px solid rgba(255, 255, 255, .16); + border-radius: 8px; + padding: .8rem; + background: rgba(255, 255, 255, .04); +} + +.ml-classification-main { + min-width: 0; +} + +.ml-classification-controls { + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + gap: .4rem; +} + +.ml-category-top, +.ml-category-buttons, +.ml-category-scopes, +.ml-criteria-helper { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.ml-category-top { + justify-content: flex-start; + margin-bottom: .55rem; +} + +.ml-category-top .ml-input { + flex: 0 1 22rem; +} + +.ml-category-scopes { + margin-bottom: 0; +} + +.ml-requirements { + display: flex; + flex-direction: column; + gap: .45rem; + margin-bottom: .55rem; + padding-left: 1rem; +} + +.ml-requirement-row { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(10rem, .75fr) minmax(14rem, 1.2fr) auto; + gap: .5rem; + align-items: start; +} + +.ml-classification-main > .ml-list-action { + margin-left: 1rem; +} + +.ml-category-condition-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + margin-left: 1rem; +} + +.ml-category-condition-left { + display: flex; + align-items: center; + gap: .75rem; + flex-wrap: wrap; +} + +.ml-match-all-check { + min-height: 2.45rem; +} + +.ml-inline-check { + display: inline-flex; + align-items: center; + gap: .3rem; +} + +.ml-value-dropdown { + position: relative; + min-width: 0; +} + +.ml-value-button { + width: 100%; + min-height: 2.45rem; + height: 2.45rem; + box-sizing: border-box; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ml-relation-select:disabled { + background: rgba(255, 255, 255, .06); + border-color: rgba(255, 255, 255, .12); + color: rgba(255, 255, 255, .38); + cursor: not-allowed; + opacity: .7; +} + +.ml-value-panel { + position: absolute; + z-index: 1000; + width: min(24rem, 90vw); + max-height: 20rem; + overflow: auto; + margin-top: .25rem; + padding: .45rem; + border: 1px solid rgba(255, 255, 255, .22); + border-radius: 6px; + background: #1f1f1f; + box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .45); +} + +.ml-value-option { + display: flex; + align-items: center; + gap: .4rem; + padding: .25rem .2rem; +} + +.ml-value-separator { + border-top: 1px solid rgba(255, 255, 255, .18); +} + +.ml-value-separator { + margin: .35rem 0; +} + +.ml-criteria-helper { + margin-top: .5rem; +} + +.ml-criteria-helper .ml-select { + flex: 1 1 10rem; +} + +.ml-criteria-helper .ml-input { + flex: 1 1 8rem; +} + +.ml-action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: .65rem .9rem; +} + +.ml-action-row { + display: grid; + grid-template-columns: minmax(5.5rem, .65fr) minmax(9rem, 1fr); + align-items: center; + gap: .5rem; +} + +.ml-action-source { + display: flex; + flex-wrap: wrap; + gap: .35rem; + margin-bottom: .8rem; +} + +.ml-action-matrix { + display: flex; + flex-direction: column; +} + +.ml-action-category-row { + display: grid; + grid-template-columns: minmax(7rem, 9rem) 1fr; + gap: .9rem; + padding: .9rem 0; + border-top: 1px solid rgba(255, 255, 255, .16); +} + +.ml-action-category-row-fallback { + border-top-width: 2px; +} + +.ml-action-category-name { + font-weight: 600; + min-width: 0; + overflow-wrap: anywhere; +} + +.ml-action-all-field { + display: block; + margin-top: .75rem; + font-weight: 400; +} + +.ml-action-all-box { + width: 100%; + min-height: 2rem; +} + +.ml-action-fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: .55rem; +} + +.ml-action-field { + min-width: 0; +} + +.ml-action-field-label { + display: block; + margin-bottom: .25rem; + font-size: .9rem; + opacity: .84; +} + +.ml-action-box { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: .25rem; + min-height: 2.25rem; + padding: .25rem; + border: 1px dashed rgba(255, 255, 255, .34); + border-radius: 6px; + background: rgba(0, 0, 0, .18); +} + +.ml-action-box.ml-over { + border-color: rgba(120, 200, 255, .9); + background: rgba(120, 200, 255, .08); +} + +.ml-action-token { + display: inline-flex; + align-items: center; + gap: .25rem; + min-height: 1.6rem; + padding: .15rem .38rem; + border: 1px solid rgba(120, 170, 255, .35); + border-radius: 5px; + background: rgba(120, 170, 255, .2); + color: rgba(255, 255, 255, .94); + cursor: grab; + user-select: none; + -webkit-user-select: none; +} + +.ml-action-token-source { + padding: .25rem .5rem; +} + +.ml-action-token-locked { + opacity: .84; +} + +.ml-action-placeholder { + border-style: dashed; + opacity: .55; +} + +.ml-action-drag-ghost { + opacity: .88; + pointer-events: none; +} + +.ml-cache-entries { + display: flex; + flex-direction: column; + gap: .35rem; + margin-top: .6rem; +} + +.ml-subheading { + margin: 1rem 0 .35rem; + font-size: 1rem; + font-weight: 600; +} + +.ml-diagnostic-row { + display: flex; + flex-direction: column; + gap: .45rem; + padding: .45rem .55rem; + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 6px; + background: rgba(255, 255, 255, .035); +} + +.ml-diagnostic-meta { + display: flex; + flex-wrap: wrap; + gap: .35rem; + align-items: center; +} + +.ml-diagnostic-chip { + display: inline-flex; + align-items: center; + min-height: 1.45rem; + padding: .12rem .38rem; + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 4px; + background: rgba(255, 255, 255, .055); + color: rgba(255, 255, 255, .72); + font-size: .86rem; +} + +.ml-diagnostic-chip-strong { + border-color: rgba(120, 170, 255, .35); + background: rgba(120, 170, 255, .14); + color: rgba(255, 255, 255, .94); +} + +.ml-cache-url { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; + line-height: 1.35; +} + +@media (max-width: 640px) { + .ml-root { + padding: .8rem; + } + + .ml-section { + padding-left: .8rem; + padding-right: .6rem; + } + + .ml-row { + align-items: stretch; + flex-direction: column; + } + + .ml-row > label { + min-width: 0; + } + + .ml-action-row { + grid-template-columns: 1fr; + } + + .ml-action-category-row { + grid-template-columns: 1fr; + } + + .ml-action-fields { + grid-template-columns: 1fr; + } + + .ml-classification-rule { + grid-template-columns: 1fr; + } + + .ml-classification-controls { + flex-direction: row; + flex-wrap: wrap; + } + + .ml-requirement-row { + grid-template-columns: 1fr; + } + +} diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/shared.js b/src/Jellyfin.Plugin.Multilang/Configuration/shared.js new file mode 100644 index 0000000..441d8d1 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Configuration/shared.js @@ -0,0 +1,99 @@ +(function () { + const basePath = () => { + const path = window.location.pathname || ""; + const marker = "/web/"; + const index = path.indexOf(marker); + return index > 0 ? path.slice(0, index).replace(/\/$/, "") : ""; + }; + + function token() { + try { + return window.ApiClient && window.ApiClient.accessToken ? window.ApiClient.accessToken() : ""; + } catch (_) { + return ""; + } + } + + function headers() { + const qs = new URLSearchParams(location.search); + const t = token() || qs.get("token") || qs.get("api_key") || ""; + const h = { "Content-Type": "application/json" }; + if (t) { + h["X-Emby-Token"] = t; + h["X-MediaBrowser-Token"] = t; + } + return h; + } + + async function request(path, options) { + const response = await fetch(`${basePath()}${path}`, { + cache: "no-store", + headers: headers(), + ...options + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + function splitList(value) { + return String(value || "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + } + + function setStatus(el, text, error) { + if (!el) return; + el.textContent = text || ""; + el.style.color = error ? "#ff9b9b" : ""; + } + + function chip(label) { + const el = document.createElement("span"); + el.className = "ml-chip"; + el.textContent = label; + el.draggable = true; + el.dataset.value = label; + el.addEventListener("dragstart", (event) => { + event.dataTransfer.setData("text/plain", label); + event.dataTransfer.effectAllowed = "move"; + el.classList.add("ml-dragging"); + }); + el.addEventListener("dragend", () => el.classList.remove("ml-dragging")); + return el; + } + + function bucketValues(bucket) { + return [...bucket.querySelectorAll(".ml-chip")].map((el) => el.dataset.value || el.textContent.trim()).filter(Boolean); + } + + function renderBucket(bucket, values) { + bucket.replaceChildren(...values.map(chip)); + } + + function wireBucket(bucket, onDrop) { + bucket.addEventListener("dragover", (event) => { + event.preventDefault(); + bucket.classList.add("ml-over"); + }); + bucket.addEventListener("dragleave", () => bucket.classList.remove("ml-over")); + bucket.addEventListener("drop", (event) => { + event.preventDefault(); + bucket.classList.remove("ml-over"); + const value = event.dataTransfer.getData("text/plain"); + if (value) onDrop(value, bucket); + }); + } + + window.Multilang = { + get: (path) => request(path), + post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }), + splitList, + setStatus, + chip, + bucketValues, + renderBucket, + wireBucket + }; +})(); diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html new file mode 100644 index 0000000..e35c0fe --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html @@ -0,0 +1,1179 @@ +
      +
      + +

      Metadata Language Rules

      + +
      +
      + Preferences +
      + +
      +
      + + +
      +
      +
      + +
      + Classification into categories +
      + +
      + +
      + Translation of categories +
      + +
      +
      +
      +
      + + + +
      + +
      + + + +
      +
      diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs new file mode 100644 index 0000000..4f78cf5 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs @@ -0,0 +1,747 @@ +using System.Globalization; +using System.Text.Json; +using Jellyfin.Plugin.Multilang.Services.Refresh; +using Jellyfin.Plugin.Multilang.Services.Providers; +using MediaBrowser.Common.Configuration; +using Microsoft.Data.Sqlite; + +namespace Jellyfin.Plugin.Multilang.Data; + +public sealed class TranslationStore +{ + private readonly string _dbPath; + + public TranslationStore(IApplicationPaths appPaths) + { + var dir = Path.Combine(appPaths.DataPath, "multilang"); + Directory.CreateDirectory(dir); + Directory.CreateDirectory(Path.Combine(dir, "assets")); + _dbPath = Path.Combine(dir, "translations.sqlite"); + Initialize(); + } + + public static string ToItemId32(Guid id) + => id.ToString("N", CultureInfo.InvariantCulture).ToLowerInvariant(); + + public static long NowUnixUtc() + => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + public static string JsonArray(IEnumerable values) + => JsonSerializer.Serialize(values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); + + public SqliteConnection Open() + => new($"Data Source={_dbPath};Cache=Shared"); + + private void Initialize() + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +CREATE TABLE IF NOT EXISTS facts ( + item_id TEXT PRIMARY KEY, + tmdb_id TEXT NOT NULL, + kind TEXT NOT NULL, + original_title TEXT NOT NULL DEFAULT '', + original_language TEXT NOT NULL DEFAULT '', + original_language_all TEXT NOT NULL DEFAULT '', + origin_countries_json TEXT NOT NULL DEFAULT '[]', + production_countries_json TEXT NOT NULL DEFAULT '[]', + spoken_languages_json TEXT NOT NULL DEFAULT '[]', + audio_track_language TEXT NOT NULL DEFAULT '', + genre_tmdb_ids_json TEXT NOT NULL DEFAULT '[]', + missing_checked_at INTEGER NOT NULL DEFAULT 0, + full_checked_at INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_facts_tmdb ON facts(tmdb_id); +CREATE INDEX IF NOT EXISTS idx_facts_kind ON facts(kind); +CREATE INDEX IF NOT EXISTS idx_facts_missing ON facts(missing_checked_at); +CREATE INDEX IF NOT EXISTS idx_facts_full ON facts(full_checked_at); + +CREATE TABLE IF NOT EXISTS translations ( + item_id TEXT NOT NULL, + lang TEXT NOT NULL, + field TEXT NOT NULL, + text TEXT NOT NULL, + PRIMARY KEY (item_id, lang, field) +); +CREATE INDEX IF NOT EXISTS idx_translations_item ON translations(item_id); + +CREATE TABLE IF NOT EXISTS assets ( + item_id TEXT NOT NULL, + lang TEXT NOT NULL, + kind TEXT NOT NULL, + path TEXT NOT NULL, + path_low TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (item_id, lang, kind) +); +CREATE INDEX IF NOT EXISTS idx_assets_item ON assets(item_id); + +CREATE TABLE IF NOT EXISTS genres ( + tmdb_id INTEGER NOT NULL, + media TEXT NOT NULL, + lang TEXT NOT NULL, + name TEXT NOT NULL, + name_norm TEXT NOT NULL, + PRIMARY KEY (tmdb_id, media, lang) +); +CREATE INDEX IF NOT EXISTS idx_genres_media_lang ON genres(media, lang); +CREATE INDEX IF NOT EXISTS idx_genres_name ON genres(name_norm, media, lang); + +CREATE TABLE IF NOT EXISTS user_rules ( + user_id TEXT PRIMARY KEY, + rules_json TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_display_langs ( + user_id TEXT PRIMARY KEY, + langs_json TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS scan_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_scan_started INTEGER NOT NULL DEFAULT 0 +); +INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0); +"; + cmd.ExecuteNonQuery(); + EnsureColumn(con, "assets", "path_low", "TEXT NOT NULL DEFAULT ''"); + } + + private static void EnsureColumn(SqliteConnection con, string table, string column, string definition) + { + using (var info = con.CreateCommand()) + { + info.CommandText = $"PRAGMA table_info({table});"; + using var reader = info.ExecuteReader(); + while (reader.Read()) + { + if (reader.GetString(1).Equals(column, StringComparison.OrdinalIgnoreCase)) + return; + } + } + + using var alter = con.CreateCommand(); + alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {definition};"; + alter.ExecuteNonQuery(); + } + + public HashSet GetTrackedItemIds() + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT item_id FROM facts;"; + using var reader = cmd.ExecuteReader(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + ids.Add(reader.GetString(0)); + return ids; + } + + public void CleanupNotInLibrary(IReadOnlySet liveItemIds) + { + using var con = Open(); + con.Open(); + using var tx = con.BeginTransaction(); + using var select = con.CreateCommand(); + select.Transaction = tx; + select.CommandText = "SELECT item_id FROM facts;"; + using var reader = select.ExecuteReader(); + var stale = new List(); + while (reader.Read()) + { + var itemId = reader.GetString(0); + if (!liveItemIds.Contains(itemId)) + stale.Add(itemId); + } + + reader.Close(); + foreach (var itemId in stale) + { + using var delete = con.CreateCommand(); + delete.Transaction = tx; + delete.CommandText = @" +DELETE FROM facts WHERE item_id = $item_id; +DELETE FROM translations WHERE item_id = $item_id; +DELETE FROM assets WHERE item_id = $item_id;"; + delete.Parameters.AddWithValue("$item_id", itemId); + delete.ExecuteNonQuery(); + } + + tx.Commit(); + } + + public long GetLastScanStarted() + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT last_scan_started FROM scan_state WHERE id = 1;"; + return cmd.ExecuteScalar() is long value ? value : 0; + } + + public void SetLastScanStarted(long value) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +INSERT INTO scan_state(id, last_scan_started) +VALUES(1, $value) +ON CONFLICT(id) DO UPDATE SET last_scan_started = excluded.last_scan_started;"; + cmd.Parameters.AddWithValue("$value", value); + cmd.ExecuteNonQuery(); + } + + public FactsStatus GetFactsStatus(string itemId) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT missing_checked_at, full_checked_at FROM facts WHERE item_id = $item_id;"; + cmd.Parameters.AddWithValue("$item_id", itemId); + using var reader = cmd.ExecuteReader(); + return reader.Read() + ? new FactsStatus(true, reader.GetInt64(0), reader.GetInt64(1)) + : new FactsStatus(false, 0, 0); + } + + public HashSet GetExistingFactsItemIds(IEnumerable itemIds) + { + var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (ids.Length == 0) + return result; + + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + var idParams = AddParams(cmd, ids, "$id"); + cmd.CommandText = $"SELECT item_id FROM facts WHERE item_id IN ({idParams});"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + result.Add(reader.GetString(0)); + + return result; + } + + public void UpsertFacts(RefreshItemInfo item, TmdbMetadata metadata, long missingCheckedAt, long fullCheckedAt) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +INSERT INTO facts( + item_id, tmdb_id, kind, original_title, original_language, original_language_all, + origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, + genre_tmdb_ids_json, missing_checked_at, full_checked_at) +VALUES( + $item_id, $tmdb_id, $kind, $original_title, $original_language, $original_language_all, + $origin_countries_json, $production_countries_json, $spoken_languages_json, $audio_track_language, + $genre_tmdb_ids_json, $missing_checked_at, $full_checked_at) +ON CONFLICT(item_id) DO UPDATE SET + tmdb_id = excluded.tmdb_id, + kind = excluded.kind, + original_title = excluded.original_title, + original_language = excluded.original_language, + original_language_all = excluded.original_language_all, + origin_countries_json = excluded.origin_countries_json, + production_countries_json = excluded.production_countries_json, + spoken_languages_json = excluded.spoken_languages_json, + audio_track_language = excluded.audio_track_language, + genre_tmdb_ids_json = excluded.genre_tmdb_ids_json, + missing_checked_at = excluded.missing_checked_at, + full_checked_at = CASE WHEN excluded.full_checked_at > 0 THEN excluded.full_checked_at ELSE facts.full_checked_at END;"; + cmd.Parameters.AddWithValue("$item_id", item.ItemId); + cmd.Parameters.AddWithValue("$tmdb_id", item.TmdbId); + cmd.Parameters.AddWithValue("$kind", item.Kind); + cmd.Parameters.AddWithValue("$original_title", metadata.OriginalTitle); + cmd.Parameters.AddWithValue("$original_language", metadata.OriginalLanguage); + cmd.Parameters.AddWithValue("$original_language_all", metadata.OriginalLanguage); + cmd.Parameters.AddWithValue("$origin_countries_json", JsonArray(metadata.OriginCountries)); + cmd.Parameters.AddWithValue("$production_countries_json", JsonArray(metadata.ProductionCountries)); + cmd.Parameters.AddWithValue("$spoken_languages_json", JsonArray(metadata.SpokenLanguages)); + cmd.Parameters.AddWithValue("$audio_track_language", item.AudioLanguage ?? string.Empty); + cmd.Parameters.AddWithValue("$genre_tmdb_ids_json", JsonSerializer.Serialize(metadata.GenreIds)); + cmd.Parameters.AddWithValue("$missing_checked_at", missingCheckedAt); + cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt); + cmd.ExecuteNonQuery(); + } + + public void MarkFactsChecked(RefreshItemInfo item, long missingCheckedAt, long fullCheckedAt) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +INSERT INTO facts( + item_id, tmdb_id, kind, original_title, original_language, original_language_all, + origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, + genre_tmdb_ids_json, missing_checked_at, full_checked_at) +VALUES( + $item_id, $tmdb_id, $kind, '', '', '', + '[]', '[]', '[]', '', + '[]', $missing_checked_at, $full_checked_at) +ON CONFLICT(item_id) DO UPDATE SET + tmdb_id = excluded.tmdb_id, + kind = excluded.kind, + missing_checked_at = excluded.missing_checked_at, + full_checked_at = CASE WHEN excluded.full_checked_at > 0 THEN excluded.full_checked_at ELSE facts.full_checked_at END;"; + cmd.Parameters.AddWithValue("$item_id", item.ItemId); + cmd.Parameters.AddWithValue("$tmdb_id", item.TmdbId); + cmd.Parameters.AddWithValue("$kind", item.Kind); + cmd.Parameters.AddWithValue("$missing_checked_at", missingCheckedAt); + cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt); + cmd.ExecuteNonQuery(); + } + + public FactsData? GetFacts(string itemId) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all, + origin_countries_json, production_countries_json, spoken_languages_json, + audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at +FROM facts +WHERE item_id = $item_id;"; + cmd.Parameters.AddWithValue("$item_id", itemId); + using var reader = cmd.ExecuteReader(); + return reader.Read() + ? new FactsData( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetString(4), + reader.GetString(5), + reader.GetString(6), + reader.GetString(7), + reader.GetString(8), + reader.GetString(9), + reader.GetString(10), + reader.GetInt64(11), + reader.GetInt64(12)) + : null; + } + + public Dictionary GetFactsForItems(IEnumerable itemIds) + { + var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (ids.Length == 0) + return result; + + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + var idParams = AddParams(cmd, ids, "$id"); + cmd.CommandText = $@" +SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all, + origin_countries_json, production_countries_json, spoken_languages_json, + audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at +FROM facts +WHERE item_id IN ({idParams});"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var facts = new FactsData( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetString(4), + reader.GetString(5), + reader.GetString(6), + reader.GetString(7), + reader.GetString(8), + reader.GetString(9), + reader.GetString(10), + reader.GetInt64(11), + reader.GetInt64(12)); + result[facts.ItemId] = facts; + } + + return result; + } + + public object[] GetTranslationsForDebug(string itemId) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT lang, field, text FROM translations WHERE item_id = $item_id ORDER BY lang, field;"; + cmd.Parameters.AddWithValue("$item_id", itemId); + using var reader = cmd.ExecuteReader(); + var rows = new List(); + while (reader.Read()) + rows.Add(new { Lang = reader.GetString(0), Field = reader.GetString(1), Text = reader.GetString(2) }); + return rows.ToArray(); + } + + public void UpsertTranslation(string itemId, string lang, string field, string text) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +INSERT INTO translations(item_id, lang, field, text) +VALUES($item_id, $lang, $field, $text) +ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;"; + cmd.Parameters.AddWithValue("$item_id", itemId); + cmd.Parameters.AddWithValue("$lang", lang); + cmd.Parameters.AddWithValue("$field", field); + cmd.Parameters.AddWithValue("$text", text); + cmd.ExecuteNonQuery(); + } + + public void UpsertAsset(string itemId, string lang, string kind, string path, long updatedAt) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at) +VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at) +ON CONFLICT(item_id, lang, kind) DO UPDATE SET + path = excluded.path, + path_low = excluded.path_low, + updated_at = excluded.updated_at;"; + cmd.Parameters.AddWithValue("$item_id", itemId); + cmd.Parameters.AddWithValue("$lang", lang); + cmd.Parameters.AddWithValue("$kind", kind); + cmd.Parameters.AddWithValue("$path", path); + cmd.Parameters.AddWithValue("$path_low", path.ToLowerInvariant()); + cmd.Parameters.AddWithValue("$updated_at", updatedAt); + cmd.ExecuteNonQuery(); + } + + public void UpsertGenre(int tmdbId, string media, string lang, string name) + { + if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name)) + return; + + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +INSERT INTO genres(tmdb_id, media, lang, name, name_norm) +VALUES($tmdb_id, $media, $lang, $name, $name_norm) +ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET + name = excluded.name, + name_norm = excluded.name_norm;"; + cmd.Parameters.AddWithValue("$tmdb_id", tmdbId); + cmd.Parameters.AddWithValue("$media", media.Trim().ToLowerInvariant()); + cmd.Parameters.AddWithValue("$lang", lang.Trim()); + cmd.Parameters.AddWithValue("$name", name.Trim()); + cmd.Parameters.AddWithValue("$name_norm", name.Trim().ToLowerInvariant()); + cmd.ExecuteNonQuery(); + } + + public GenreRow[] GetGenres(string media) + { + if (string.IsNullOrWhiteSpace(media)) + return []; + + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = @" +SELECT tmdb_id, media, lang, name +FROM genres +WHERE media = $media +ORDER BY tmdb_id, lang;"; + cmd.Parameters.AddWithValue("$media", media.Trim().ToLowerInvariant()); + using var reader = cmd.ExecuteReader(); + var rows = new List(); + while (reader.Read()) + rows.Add(new GenreRow(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3))); + + return rows.ToArray(); + } + + public Dictionary GetGenreNames(string media, string lang) + { + if (string.IsNullOrWhiteSpace(media)) + return []; + + var rows = GetGenres(media) + .GroupBy(r => r.TmdbId) + .Select(g => new { Id = g.Key, Name = PickGenreName(g, lang) }) + .Where(g => !string.IsNullOrWhiteSpace(g.Name)) + .ToDictionary(g => g.Id, g => g.Name, EqualityComparer.Default); + return rows; + } + + private static string PickGenreName(IEnumerable rows, string lang) + { + var candidates = rows.ToArray(); + var requested = (lang ?? string.Empty).Trim(); + var requestedBase = requested.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + return candidates.FirstOrDefault(r => r.Lang.Equals(requested, StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault(r => !string.IsNullOrWhiteSpace(requestedBase) && r.Lang.Equals(requestedBase, StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault(r => r.Lang.Equals("en-US", StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault(r => r.Lang.Equals("en", StringComparison.OrdinalIgnoreCase)).Name ?? + candidates.FirstOrDefault().Name; + } + + public Dictionary>> GetTranslations( + IEnumerable itemIds, + IEnumerable languages) + { + var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var result = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + if (ids.Length == 0 || langs.Length == 0) + return result; + + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + var idParams = AddParams(cmd, ids, "$id"); + var langParams = AddParams(cmd, langs, "$lang"); + cmd.CommandText = $@" +SELECT item_id, lang, field, text +FROM translations +WHERE item_id IN ({idParams}) AND lang IN ({langParams});"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var itemId = reader.GetString(0); + var lang = reader.GetString(1); + var field = reader.GetString(2); + var text = reader.GetString(3); + if (!result.TryGetValue(itemId, out var byLang)) + { + byLang = new Dictionary>(StringComparer.OrdinalIgnoreCase); + result[itemId] = byLang; + } + + if (!byLang.TryGetValue(lang, out var byField)) + { + byField = new Dictionary(StringComparer.OrdinalIgnoreCase); + byLang[lang] = byField; + } + + byField[field] = text; + } + + return result; + } + + public Dictionary>> GetAssets( + IEnumerable itemIds, + IEnumerable languages) + { + var ids = itemIds.Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var result = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + if (ids.Length == 0 || langs.Length == 0) + return result; + + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + var idParams = AddParams(cmd, ids, "$id"); + var langParams = AddParams(cmd, langs, "$asset_lang"); + cmd.CommandText = $@" +SELECT item_id, kind, lang, path +FROM assets +WHERE item_id IN ({idParams}) AND lang IN ({langParams});"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var itemId = reader.GetString(0); + var kind = reader.GetString(1); + var lang = reader.GetString(2); + var path = reader.GetString(3); + if (!result.TryGetValue(itemId, out var byKind)) + { + byKind = new Dictionary>(StringComparer.OrdinalIgnoreCase); + result[itemId] = byKind; + } + + if (!byKind.TryGetValue(kind, out var byLang)) + { + byLang = new Dictionary(StringComparer.OrdinalIgnoreCase); + byKind[kind] = byLang; + } + + byLang[lang] = path; + } + + return result; + } + + public void SaveUserRules(string userId, UserRulesDocument rules) + { + using var con = Open(); + con.Open(); + using var tx = con.BeginTransaction(); + using (var cmd = con.CreateCommand()) + { + cmd.Transaction = tx; + cmd.CommandText = @" +INSERT INTO user_rules(user_id, rules_json, updated_at) +VALUES($user_id, $rules_json, $updated_at) +ON CONFLICT(user_id) DO UPDATE SET rules_json = excluded.rules_json, updated_at = excluded.updated_at;"; + cmd.Parameters.AddWithValue("$user_id", userId); + cmd.Parameters.AddWithValue("$rules_json", JsonSerializer.Serialize(rules)); + cmd.Parameters.AddWithValue("$updated_at", NowUnixUtc()); + cmd.ExecuteNonQuery(); + } + + using (var cmd = con.CreateCommand()) + { + cmd.Transaction = tx; + cmd.CommandText = @" +INSERT INTO user_display_langs(user_id, langs_json, updated_at) +VALUES($user_id, $langs_json, $updated_at) +ON CONFLICT(user_id) DO UPDATE SET langs_json = excluded.langs_json, updated_at = excluded.updated_at;"; + cmd.Parameters.AddWithValue("$user_id", userId); + cmd.Parameters.AddWithValue("$langs_json", JsonSerializer.Serialize(rules.FallbackLanguages)); + cmd.Parameters.AddWithValue("$updated_at", NowUnixUtc()); + cmd.ExecuteNonQuery(); + } + + tx.Commit(); + } + + public UserRulesDocument? GetUserRules(string userId) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT rules_json FROM user_rules WHERE user_id = $user_id;"; + cmd.Parameters.AddWithValue("$user_id", userId); + var json = cmd.ExecuteScalar() as string; + return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json); + } + + private static string AddParams(SqliteCommand cmd, IReadOnlyList values, string prefix) + { + var names = new string[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var name = $"{prefix}{i}"; + names[i] = name; + cmd.Parameters.AddWithValue(name, values[i]); + } + + return string.Join(",", names); + } +} + +public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt); + +public readonly record struct GenreRow(int TmdbId, string Media, string Lang, string Name); + +public sealed record FactsData( + string ItemId, + string TmdbId, + string Kind, + string OriginalTitle, + string OriginalLanguage, + string OriginalLanguageAll, + string OriginCountriesJson, + string ProductionCountriesJson, + string SpokenLanguagesJson, + string AudioTrackLanguage, + string GenreTmdbIdsJson, + long MissingCheckedAt, + long FullCheckedAt); + +public sealed class UserRulesDocument +{ + public bool Enabled { get; set; } = true; + + public string SortLocale { get; set; } = "Auto"; + + public string[] FallbackLanguages { get; set; } = []; + + public Dictionary FallbackFieldActions { get; set; } = new(StringComparer.OrdinalIgnoreCase) + { + ["title"] = ["Jellyfin"], + ["overview"] = ["Jellyfin"], + ["tagline"] = ["Jellyfin"], + ["poster"] = ["Jellyfin"], + ["logo"] = ["Jellyfin"], + ["banner"] = ["Jellyfin"], + ["thumb"] = ["Jellyfin"], + ["backdrop"] = ["Jellyfin"] + }; + + public bool TrustTmdbCollections { get; set; } = true; + + public UserCategoryRule[] Categories { get; set; } = []; + + public UserRuleEntry[] Rules { get; set; } = []; +} + +public sealed class UserRuleEntry +{ + public string Text { get; set; } = string.Empty; + + public string[] Languages { get; set; } = []; + + public string[] Scopes { get; set; } = []; +} + +public sealed class UserCategoryRule +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + public string Label { get; set; } = string.Empty; + + public string CriteriaText { get; set; } = string.Empty; + + public UserCategoryRequirement[] Requirements { get; set; } = []; + + public bool MatchAllConditions { get; set; } = true; + + public string[] Scopes { get; set; } = ["M", "S", "C"]; + + public Dictionary FieldActions { get; set; } = new(StringComparer.OrdinalIgnoreCase) + { + ["title"] = "Fallback", + ["overview"] = "Fallback", + ["tagline"] = "Fallback", + ["poster"] = "Fallback", + ["logo"] = "Fallback", + ["banner"] = "Fallback", + ["thumb"] = "Fallback", + ["backdrop"] = "Fallback" + }; + + public Dictionary FieldActionLists { get; set; } = new(StringComparer.OrdinalIgnoreCase) + { + ["title"] = ["Jellyfin"], + ["overview"] = ["Jellyfin"], + ["tagline"] = ["Jellyfin"], + ["poster"] = ["Jellyfin"], + ["logo"] = ["Jellyfin"], + ["banner"] = ["Jellyfin"], + ["thumb"] = ["Jellyfin"], + ["backdrop"] = ["Jellyfin"] + }; +} + +public sealed class UserCategoryRequirement +{ + public string Field { get; set; } = string.Empty; + + public string[] Fields { get; set; } = []; + + public bool UseFieldOr { get; set; } + + public string Relation { get; set; } = string.Empty; + + public bool UseOr { get; set; } + + public string[] Values { get; set; } = []; +} diff --git a/src/Jellyfin.Plugin.Multilang/Jellyfin.Plugin.Multilang.csproj b/src/Jellyfin.Plugin.Multilang/Jellyfin.Plugin.Multilang.csproj new file mode 100644 index 0000000..f340cc9 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Jellyfin.Plugin.Multilang.csproj @@ -0,0 +1,40 @@ + + + + Jellyfin.Plugin.Multilang + net9.0 + enable + enable + false + 0.1.0 + 0.1.0.0 + 0.1.0.0 + ajp_anton + + + + + + + + + runtime + + + runtime + + + + + + + + + + + + Jellyfin.Plugin.Multilang.inject.js + + + + diff --git a/src/Jellyfin.Plugin.Multilang/Plugin.cs b/src/Jellyfin.Plugin.Multilang/Plugin.cs new file mode 100644 index 0000000..34bf635 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Plugin.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using Jellyfin.Plugin.Multilang.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.Multilang; + +public sealed class Plugin : BasePlugin, IHasWebPages +{ + public static Plugin? Instance { get; private set; } + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public override string Name => "Multilang"; + + public override Guid Id => Guid.Parse("9d7c61a5-7a1b-4d1e-9b79-4c8c6d2e8a2a"); + + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = "Multilang", + DisplayName = "Multilang", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace), + EnableInMainMenu = true, + MenuSection = "plugins", + MenuIcon = "translate" + }; + + yield return new PluginPageInfo + { + Name = "MultilangUser", + DisplayName = "Multilang User Rules", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.userRulesPage.html", + GetType().Namespace) + }; + } +} diff --git a/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs b/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs new file mode 100644 index 0000000..a9aafcc --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs @@ -0,0 +1,32 @@ +using Jellyfin.Plugin.Multilang.Data; +using Jellyfin.Plugin.Multilang.ScheduledTasks; +using Jellyfin.Plugin.Multilang.Services; +using Jellyfin.Plugin.Multilang.Services.Providers; +using Jellyfin.Plugin.Multilang.Services.Refresh; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.Multilang; + +public sealed class PluginServiceRegistrator : IPluginServiceRegistrator +{ + public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost) + { + services.AddMvc().AddApplicationPart(typeof(Plugin).Assembly); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Rules/CategoryRuleEvaluator.cs b/src/Jellyfin.Plugin.Multilang/Rules/CategoryRuleEvaluator.cs new file mode 100644 index 0000000..75d52f0 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Rules/CategoryRuleEvaluator.cs @@ -0,0 +1,328 @@ +using System.Text.Json; +using Jellyfin.Plugin.Multilang.Data; + +namespace Jellyfin.Plugin.Multilang.Rules; + +public sealed record CategoryRuleTrace( + string Id, + string Label, + string Kind, + string[] Scopes, + bool ScopeMatches, + bool MatchAllConditions, + bool UsesStructuredRequirements, + bool Result, + RequirementTrace[] Requirements, + string CriteriaText); + +public sealed record RequirementTrace( + string[] Fields, + bool UseFieldOr, + string Relation, + bool UseOr, + string[] Expected, + bool Result, + RequirementFieldTrace[] FieldResults); + +public sealed record RequirementFieldTrace( + string Field, + string[] Actual, + bool Result); + +public static class CategoryRuleEvaluator +{ + public static bool Matches(UserCategoryRule category, FactsData facts) + { + return Trace(category, facts).Result; + } + + public static CategoryRuleTrace Trace(UserCategoryRule category, FactsData facts) + { + var scopeMatches = ScopeMatches(category, facts.Kind); + if (!scopeMatches) + { + return new CategoryRuleTrace( + category.Id, + category.Label, + facts.Kind, + category.Scopes ?? [], + false, + category.MatchAllConditions, + category.Requirements is { Length: > 0 }, + false, + [], + category.CriteriaText ?? string.Empty); + } + + if (category.Requirements is { Length: > 0 }) + { + var requirements = category.Requirements.Select(requirement => TraceRequirement(requirement, facts)).ToArray(); + var result = category.MatchAllConditions + ? requirements.All(requirement => requirement.Result) + : requirements.Any(requirement => requirement.Result); + return new CategoryRuleTrace( + category.Id, + category.Label, + facts.Kind, + category.Scopes ?? [], + true, + category.MatchAllConditions, + true, + result, + requirements, + category.CriteriaText ?? string.Empty); + } + + var criteria = (category.CriteriaText ?? string.Empty).Trim(); + var criteriaResult = criteria.Length > 0 && EvaluateCriteria(criteria, facts); + return new CategoryRuleTrace( + category.Id, + category.Label, + facts.Kind, + category.Scopes ?? [], + true, + category.MatchAllConditions, + false, + criteriaResult, + [], + criteria); + } + + public static string[] ParseJsonStringArray(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return []; + + try + { + return JsonSerializer.Deserialize(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static bool ScopeMatches(UserCategoryRule category, string kind) + { + var scopes = category.Scopes ?? []; + if (scopes.Length == 0) + return true; + + var wanted = kind switch + { + "movie" => "M", + "collection" => "C", + "tv" or "tvseason" or "tvepisode" => "S", + _ => string.Empty + }; + + return scopes.Contains(wanted, StringComparer.OrdinalIgnoreCase); + } + + private static bool EvaluateCriteria(string criteria, FactsData facts) + { + var orGroups = SplitByOperator(criteria, "or"); + foreach (var group in orGroups) + { + var terms = SplitByOperator(group, "and"); + if (terms.All(term => EvaluateTerm(term, facts))) + return true; + } + + return false; + } + + private static bool EvaluateTerm(string term, FactsData facts) + { + term = term.Trim(); + if (term.StartsWith("not ", StringComparison.OrdinalIgnoreCase)) + return !EvaluateTerm(term[4..], facts); + + var relation = FindRelation(term); + if (relation is null) + return false; + + var field = term[..relation.Value.Index].Trim(); + var rawValue = term[(relation.Value.Index + relation.Value.Token.Length)..].Trim().Trim('"', '\''); + var result = relation.Value.Token switch + { + "contains" => FieldValues(field, facts, all: true).Any(v => ValueMatches(v, rawValue, IsLanguageField(field))), + "not_contains" => !FieldValues(field, facts, all: true).Any(v => ValueMatches(v, rawValue, IsLanguageField(field))), + "is" => FieldValues(field, facts, all: false) is { Length: 1 } values && ValueMatches(values[0], rawValue, IsLanguageField(field)), + "is_not" => !(FieldValues(field, facts, all: false) is { Length: 1 } values && ValueMatches(values[0], rawValue, IsLanguageField(field))), + _ => false + }; + return result; + } + + private static bool EvaluateRequirement(UserCategoryRequirement requirement, FactsData facts) + => TraceRequirement(requirement, facts).Result; + + private static RequirementTrace TraceRequirement(UserCategoryRequirement requirement, FactsData facts) + { + var fields = RequirementFields(requirement); + var relation = (requirement.Relation ?? string.Empty).Trim(); + var expected = (requirement.Values ?? []) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (fields.Length == 0 || relation.Length == 0 || expected.Length == 0) + { + return new RequirementTrace( + fields, + requirement.UseFieldOr, + relation, + requirement.UseOr, + expected, + false, + []); + } + + var fieldResults = fields + .Select(field => TraceRequirementField(field, relation, expected, requirement.UseOr, facts)) + .ToArray(); + var result = requirement.UseFieldOr + ? fieldResults.Any(field => field.Result) + : fieldResults.All(field => field.Result); + return new RequirementTrace( + fields, + requirement.UseFieldOr, + relation, + requirement.UseOr, + expected, + result, + fieldResults); + } + + private static bool EvaluateRequirementField(string field, string relation, string[] expected, bool useOr, FactsData facts) + => TraceRequirementField(field, relation, expected, useOr, facts).Result; + + private static RequirementFieldTrace TraceRequirementField(string field, string relation, string[] expected, bool useOr, FactsData facts) + { + var actual = FieldValues(field, facts, all: true) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var result = relation switch + { + "is" => RequirementIs(actual, expected, useOr), + "is_not" => !RequirementIs(actual, expected, useOr), + "contains" => RequirementContains(actual, expected, useOr), + "not_contains" => !RequirementContains(actual, expected, useOr), + _ => false + }; + return new RequirementFieldTrace(field, actual, result); + } + + private static string[] RequirementFields(UserCategoryRequirement requirement) + { + var fields = (requirement.Fields ?? []) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (fields.Length > 0) + return fields; + + var field = (requirement.Field ?? string.Empty).Trim(); + return field.Length == 0 ? [] : [field]; + } + + private static bool RequirementIs(string[] actual, string[] expected, bool useOr) + { + if (expected.Length == 1) + return actual.Length == 1 && EqualsValue(actual[0], expected[0]); + + if (useOr) + return actual.Length == 1 && expected.Any(value => EqualsValue(actual[0], value)); + + return actual.Length == expected.Length && + expected.All(expectedValue => actual.Any(actualValue => EqualsValue(actualValue, expectedValue))); + } + + private static bool RequirementContains(string[] actual, string[] expected, bool useOr) + { + if (expected.Length == 1) + return actual.Any(actualValue => EqualsValue(actualValue, expected[0])); + + return useOr + ? expected.Any(expectedValue => actual.Any(actualValue => EqualsValue(actualValue, expectedValue))) + : expected.All(expectedValue => actual.Any(actualValue => EqualsValue(actualValue, expectedValue))); + } + + private static bool EqualsValue(string actual, string expected) + => string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); + + private static (string Token, int Index)? FindRelation(string term) + { + foreach (var token in new[] { "not_contains", "contains", "is_not", "is" }) + { + var needle = " " + token + " "; + var index = term.IndexOf(needle, StringComparison.OrdinalIgnoreCase); + if (index >= 0) + return (token, index + 1); + } + + return null; + } + + private static string[] FieldValues(string field, FactsData facts, bool all) + { + field = field.Trim().ToLowerInvariant(); + return field switch + { + "origin_countries" => ParseJsonStringArray(facts.OriginCountriesJson), + "production_countries" => ParseJsonStringArray(facts.ProductionCountriesJson), + "spoken_languages" => ParseJsonStringArray(facts.SpokenLanguagesJson), + "original_language" => string.IsNullOrWhiteSpace(facts.OriginalLanguage) ? [] : [facts.OriginalLanguage], + "audio_language" => string.IsNullOrWhiteSpace(facts.AudioTrackLanguage) ? [] : [facts.AudioTrackLanguage], + _ => [] + }; + } + + private static bool IsLanguageField(string field) + => field.Equals("spoken_languages", StringComparison.OrdinalIgnoreCase) || + field.Equals("original_language", StringComparison.OrdinalIgnoreCase) || + field.Equals("audio_language", StringComparison.OrdinalIgnoreCase); + + private static bool ValueMatches(string actual, string expected, bool language) + { + if (expected.Equals("empty", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(actual); + if (!language) + return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); + + if (expected.Contains('-', StringComparison.Ordinal)) + return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); + return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) || + actual.StartsWith(expected + "-", StringComparison.OrdinalIgnoreCase); + } + + private static string[] SplitByOperator(string text, string op) + { + var parts = new List(); + var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var current = new List(); + foreach (var token in tokens) + { + if (token.Equals(op, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(string.Join(' ', current)); + current.Clear(); + } + else + { + current.Add(token); + } + } + + if (current.Count > 0) + parts.Add(string.Join(' ', current)); + return parts.Where(p => p.Trim().Length > 0).ToArray(); + } +} diff --git a/src/Jellyfin.Plugin.Multilang/ScheduledTasks/InjectStartupTask.cs b/src/Jellyfin.Plugin.Multilang/ScheduledTasks/InjectStartupTask.cs new file mode 100644 index 0000000..0ed812a --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/ScheduledTasks/InjectStartupTask.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using Jellyfin.Plugin.Multilang.Services.Injection; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Multilang.ScheduledTasks; + +public sealed class InjectStartupTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly IApplicationPaths _appPaths; + private readonly ILogger _logger; + + public InjectStartupTask(IApplicationPaths appPaths, ILogger logger) + { + _appPaths = appPaths; + _logger = logger; + } + + public string Name => "Multilang: Inject Web Script"; + public string Key => "MultilangInjectWebScript"; + public string Description => "Injects Multilang script into the Jellyfin web UI."; + public string Category => "Multilang"; + public bool IsHidden => true; + public bool IsEnabled => true; + public bool IsLogged => false; + + public IEnumerable GetDefaultTriggers() + => [new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger }]; + + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version?.ToString() ?? "0"; + var stamp = !string.IsNullOrWhiteSpace(assembly.Location) && File.Exists(assembly.Location) + ? File.GetLastWriteTimeUtc(assembly.Location).Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture) + : version; + var scriptTag = $""; + WebScriptInjector.EnsureInjected(_appPaths.WebPath, scriptTag, _logger); + progress.Report(100); + return Task.CompletedTask; + } +} diff --git a/src/Jellyfin.Plugin.Multilang/ScheduledTasks/RefreshFullTask.cs b/src/Jellyfin.Plugin.Multilang/ScheduledTasks/RefreshFullTask.cs new file mode 100644 index 0000000..79ee137 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/ScheduledTasks/RefreshFullTask.cs @@ -0,0 +1,28 @@ +using Jellyfin.Plugin.Multilang.Services.Refresh; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.Plugin.Multilang.ScheduledTasks; + +public sealed class RefreshFullTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly RefreshService _refreshService; + + public RefreshFullTask(RefreshService refreshService) + { + _refreshService = refreshService; + } + + public string Name => "Multilang: Full Refresh"; + public string Key => "MultilangRewriteRefreshFull"; + public string Description => "Refreshes multilingual metadata and facts using configured providers and languages."; + public string Category => "Multilang"; + public bool IsHidden => false; + public bool IsEnabled => true; + public bool IsLogged => true; + + public IEnumerable GetDefaultTriggers() + => [new TaskTriggerInfo { Type = TaskTriggerInfoType.WeeklyTrigger, DayOfWeek = DayOfWeek.Sunday, TimeOfDayTicks = TimeSpan.FromHours(4).Ticks }]; + + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + => _refreshService.RunScanAsync(RefreshJobType.Full, progress, cancellationToken, "scheduled"); +} diff --git a/src/Jellyfin.Plugin.Multilang/ScheduledTasks/RefreshMissingTask.cs b/src/Jellyfin.Plugin.Multilang/ScheduledTasks/RefreshMissingTask.cs new file mode 100644 index 0000000..838e748 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/ScheduledTasks/RefreshMissingTask.cs @@ -0,0 +1,28 @@ +using Jellyfin.Plugin.Multilang.Services.Refresh; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.Plugin.Multilang.ScheduledTasks; + +public sealed class RefreshMissingTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly RefreshService _refreshService; + + public RefreshMissingTask(RefreshService refreshService) + { + _refreshService = refreshService; + } + + public string Name => "Multilang: Refresh Missing Data"; + public string Key => "MultilangRewriteRefreshMissing"; + public string Description => "Fetches missing multilingual metadata using configured providers and languages."; + public string Category => "Multilang"; + public bool IsHidden => false; + public bool IsEnabled => true; + public bool IsLogged => true; + + public IEnumerable GetDefaultTriggers() + => [new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(6).Ticks }]; + + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + => _refreshService.RunScanAsync(RefreshJobType.Missing, progress, cancellationToken, "scheduled"); +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/Injection/WebScriptInjector.cs b/src/Jellyfin.Plugin.Multilang/Services/Injection/WebScriptInjector.cs new file mode 100644 index 0000000..fc62a44 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Injection/WebScriptInjector.cs @@ -0,0 +1,51 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Multilang.Services.Injection; + +internal static class WebScriptInjector +{ + private const string StartComment = ""; + private const string EndComment = ""; + + public static bool EnsureInjected(string webRootPath, string scriptTag, ILogger logger) + { + if (string.IsNullOrWhiteSpace(webRootPath)) + return false; + + var indexPath = Path.Combine(webRootPath, "index.html"); + if (!File.Exists(indexPath)) + { + logger.LogWarning("Multilang inject: index.html not found at {Path}", indexPath); + return false; + } + + var content = File.ReadAllText(indexPath); + var block = $"{StartComment}\n{scriptTag}\n{EndComment}\n"; + if (content.Contains(block, StringComparison.Ordinal)) + return false; + + content = RemoveOldBlock(content); + var insertAt = content.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (insertAt < 0) + { + logger.LogWarning("Multilang inject: tag not found in index.html"); + return false; + } + + File.WriteAllText(indexPath, content.Insert(insertAt, block + Environment.NewLine), Encoding.UTF8); + logger.LogInformation("Multilang inject: updated index.html"); + return true; + } + + private static string RemoveOldBlock(string content) + { + var start = content.IndexOf(StartComment, StringComparison.Ordinal); + if (start < 0) + return content; + var end = content.IndexOf(EndComment, start, StringComparison.Ordinal); + if (end < 0) + return content; + return content.Remove(start, end + EndComment.Length - start); + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs new file mode 100644 index 0000000..34dfe9f --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyCache.cs @@ -0,0 +1,309 @@ +namespace Jellyfin.Plugin.Multilang.Services; + +public sealed class ItemsProxyCache +{ + private sealed class Entry + { + public required string Key { get; init; } + + public required string UserId { get; init; } + + public required string Url { get; init; } + + public required HashSet ItemIds { get; init; } + + public required string Body { get; init; } + + public required string ContentType { get; init; } + + public required DateTimeOffset CreatedUtc { get; init; } + + public required long DurationMs { get; init; } + + public required long SizeBytes { get; init; } + } + + private sealed class RecentRequest + { + public required string Url { get; init; } + + public required DateTimeOffset CreatedUtc { get; init; } + + public required bool CacheHit { get; init; } + + public required bool CacheStored { get; init; } + + public required int StatusCode { get; init; } + + public required int ItemCount { get; init; } + + public required long TotalMs { get; init; } + + public required long UpstreamMs { get; init; } + + public required long TransformMs { get; init; } + + public required long SizeBytes { get; init; } + } + + private readonly object _lock = new(); + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly Dictionary _microEntries = new(StringComparer.Ordinal); + private readonly Queue _recentRequests = new(); + private long _totalBytes; + private const int MaxRecentRequests = 100; + private const int MaxMicroEntries = 50; + private static readonly TimeSpan MicroTtl = TimeSpan.FromSeconds(3); + + public void ClearAll() + { + lock (_lock) + { + _entries.Clear(); + _microEntries.Clear(); + _totalBytes = 0; + } + } + + public void InvalidateItems(IEnumerable itemIds) + { + var ids = itemIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim().ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (ids.Count == 0) + return; + + lock (_lock) + { + foreach (var key in _entries.Values + .Where(e => e.ItemIds.Overlaps(ids)) + .Select(e => e.Key) + .ToArray()) + { + Remove(key); + } + + foreach (var key in _microEntries.Values + .Where(e => e.ItemIds.Overlaps(ids)) + .Select(e => e.Key) + .ToArray()) + { + _microEntries.Remove(key); + } + } + } + + public bool TryGet(string key, TimeSpan ttl, out CachedItemsProxyResponse response) + { + lock (_lock) + { + PurgeExpired(ttl); + if (!_entries.TryGetValue(key, out var entry)) + { + PurgeExpired(_microEntries, MicroTtl); + if (!_microEntries.TryGetValue(key, out var microEntry)) + { + response = default; + return false; + } + + response = new CachedItemsProxyResponse(microEntry.Body, microEntry.ContentType, microEntry.ItemIds.Count, microEntry.SizeBytes); + return true; + } + + response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemIds.Count, entry.SizeBytes); + return true; + } + } + + public bool Store(string key, string userId, string url, IEnumerable itemIds, string body, string contentType, long durationMs, long maxBytes, TimeSpan ttl) + { + if (maxBytes <= 0 || ttl <= TimeSpan.Zero) + return false; + + var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body); + if (sizeBytes > maxBytes) + return false; + + lock (_lock) + { + PurgeExpired(ttl); + Remove(key); + + while (_totalBytes + sizeBytes > maxBytes && _entries.Count > 0) + { + var victim = _entries.Values.OrderBy(e => e.CreatedUtc).First(); + Remove(victim.Key); + } + + if (_totalBytes + sizeBytes > maxBytes) + return false; + + _entries[key] = new Entry + { + Key = key, + UserId = userId, + Url = url, + ItemIds = itemIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim().ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase), + Body = body, + ContentType = contentType, + CreatedUtc = DateTimeOffset.UtcNow, + DurationMs = durationMs, + SizeBytes = sizeBytes + }; + _totalBytes += sizeBytes; + return true; + } + } + + public bool StoreMicro(string key, string userId, string url, IEnumerable itemIds, string body, string contentType, long durationMs, long maxBytes) + { + if (maxBytes <= 0) + return false; + + var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body); + if (sizeBytes > maxBytes) + return false; + + lock (_lock) + { + PurgeExpired(_microEntries, MicroTtl); + _microEntries.Remove(key); + + while (_microEntries.Count >= MaxMicroEntries) + { + var victim = _microEntries.Values.OrderBy(e => e.CreatedUtc).First(); + _microEntries.Remove(victim.Key); + } + + _microEntries[key] = new Entry + { + Key = key, + UserId = userId, + Url = url, + ItemIds = itemIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim().ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase), + Body = body, + ContentType = contentType, + CreatedUtc = DateTimeOffset.UtcNow, + DurationMs = durationMs, + SizeBytes = sizeBytes + }; + return true; + } + } + + public object[] GetEntries() + { + lock (_lock) + { + var now = DateTimeOffset.UtcNow; + return _entries.Values + .OrderByDescending(e => e.CreatedUtc) + .Select(e => new + { + e.UserId, + e.Url, + AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds, + e.DurationMs, + e.SizeBytes, + ItemCount = e.ItemIds.Count + }) + .Cast() + .ToArray(); + } + } + + public void RecordRequest( + string url, + bool cacheHit, + bool cacheStored, + int statusCode, + int itemCount, + long totalMs, + long upstreamMs, + long transformMs, + long sizeBytes) + { + lock (_lock) + { + _recentRequests.Enqueue(new RecentRequest + { + Url = url, + CreatedUtc = DateTimeOffset.UtcNow, + CacheHit = cacheHit, + CacheStored = cacheStored, + StatusCode = statusCode, + ItemCount = itemCount, + TotalMs = totalMs, + UpstreamMs = upstreamMs, + TransformMs = transformMs, + SizeBytes = sizeBytes + }); + + while (_recentRequests.Count > MaxRecentRequests) + _recentRequests.Dequeue(); + } + } + + public object[] GetRecentRequests() + { + lock (_lock) + { + var now = DateTimeOffset.UtcNow; + return _recentRequests + .Reverse() + .Select(e => new + { + e.Url, + AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds, + e.CacheHit, + e.CacheStored, + e.StatusCode, + e.ItemCount, + e.TotalMs, + e.UpstreamMs, + e.TransformMs, + e.SizeBytes + }) + .Cast() + .ToArray(); + } + } + + private void PurgeExpired(TimeSpan ttl) + { + if (ttl <= TimeSpan.Zero || _entries.Count == 0) + return; + + var now = DateTimeOffset.UtcNow; + foreach (var key in _entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray()) + Remove(key); + } + + private static void PurgeExpired(Dictionary entries, TimeSpan ttl) + { + if (ttl <= TimeSpan.Zero || entries.Count == 0) + return; + + var now = DateTimeOffset.UtcNow; + foreach (var key in entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray()) + entries.Remove(key); + } + + private void Remove(string key) + { + if (!_entries.Remove(key, out var existing)) + return; + + _totalBytes = Math.Max(0, _totalBytes - existing.SizeBytes); + } +} + +public readonly record struct CachedItemsProxyResponse(string Body, string ContentType, int ItemCount, long SizeBytes); diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs new file mode 100644 index 0000000..fb3ef8c --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; + +namespace Jellyfin.Plugin.Multilang.Services; + +public sealed record ItemsProxyControls( + string SortBy, + string SortOrder, + string NameStartsWith, + int StartIndex, + int Limit, + string ClientLocale, + string GenreIds); + +public static class ItemsProxySorting +{ + private readonly record struct SortSpec(string Field, SortKind Kind); + + private enum SortKind + { + Text, + Number, + Date, + SortName, + SeriesSortName, + Random + } + + public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles) + { + if (!TryGetItemsArray(root, out var itemsArray)) + return; + + var items = itemsArray.OfType().ToList(); + var specs = BuildSortSpecs(controls.SortBy); + if (specs.Length > 0 && items.Count > 1) + items = Sort(items, specs, controls.SortOrder, culture, titleArticles); + + items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticles); + var filteredCount = items.Count; + items = Slice(items, controls.StartIndex, controls.Limit); + + itemsArray.Clear(); + foreach (var item in items) + itemsArray.Add(item); + + if (root is JsonObject obj && obj.ContainsKey("TotalRecordCount")) + { + obj["TotalRecordCount"] = filteredCount; + obj["StartIndex"] = Math.Clamp(controls.StartIndex, 0, filteredCount); + } + } + + private static bool TryGetItemsArray(JsonNode root, out JsonArray items) + { + if (root is JsonObject obj && obj["Items"] is JsonArray array) + { + items = array; + return true; + } + + if (root is JsonArray direct) + { + items = direct; + return true; + } + + items = []; + return false; + } + + private static SortSpec[] BuildSortSpecs(string sortBy) + { + var fields = (sortBy ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(f => f.Length > 0) + .ToList(); + if (fields.Count == 0) + return []; + + if (!fields.Any(f => f.Equals("SortName", StringComparison.OrdinalIgnoreCase))) + fields.Add("SortName"); + if (!fields.Any(f => f.Equals("ProductionYear", StringComparison.OrdinalIgnoreCase))) + fields.Add("ProductionYear"); + return fields.Select(f => new SortSpec(f, KindFor(f))).ToArray(); + } + + private static SortKind KindFor(string field) + => field.ToLowerInvariant() switch + { + "sortname" => SortKind.SortName, + "seriessortname" => SortKind.SeriesSortName, + "random" => SortKind.Random, + "isfolder" or "productionyear" or "playcount" or "communityrating" or "criticrating" or "runtimeticks" or "runtime" or "indexnumber" or "parentindexnumber" => SortKind.Number, + "premieredate" or "datecreated" or "dateplayed" => SortKind.Date, + _ => SortKind.Text + }; + + private static List Sort(List items, SortSpec[] specs, string sortOrder, CultureInfo culture, string[] titleArticles) + { + var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true; + var comparer = StringComparer.Create(culture, true); + var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticles)).ToArray() }).ToList(); + + values.Sort((left, right) => + { + for (var i = 0; i < specs.Length; i++) + { + var a = left.Values[i]; + var b = right.Values[i]; + if (!a.HasValue && !b.HasValue) + continue; + if (!a.HasValue) + return descending ? 1 : -1; + if (!b.HasValue) + return descending ? -1 : 1; + + var cmp = specs[i].Kind is SortKind.Text or SortKind.SortName or SortKind.SeriesSortName + ? comparer.Compare(a.Text, b.Text) + : a.Number.CompareTo(b.Number); + if (cmp != 0) + return descending ? -cmp : cmp; + } + + return 0; + }); + + return values.Select(v => v.Item).ToList(); + } + + private static SortValue ValueFor(JsonObject item, SortSpec spec, CultureInfo culture, string[] titleArticles) + { + return spec.Kind switch + { + SortKind.Random => new SortValue(true, string.Empty, Random.Shared.NextDouble()), + SortKind.SortName => new SortValue(true, NormalizeSortText(GetString(item, "Name"), titleArticles, culture), 0), + SortKind.SeriesSortName => new SortValue(true, NormalizeSortText(GetString(item, "SeriesName"), titleArticles, culture), 0), + SortKind.Number => TryGetNumber(item, spec.Field, out var number) ? new SortValue(true, string.Empty, number) : default, + SortKind.Date => TryGetDate(item, spec.Field, out var ticks) ? new SortValue(true, string.Empty, ticks) : default, + _ => !string.IsNullOrWhiteSpace(GetString(item, spec.Field)) ? new SortValue(true, GetString(item, spec.Field), 0) : default + }; + } + + private readonly record struct SortValue(bool HasValue, string Text, double Number); + + private static List FilterByNameStartsWith(List items, string prefix, CultureInfo culture, string[] titleArticles) + { + prefix = (prefix ?? string.Empty).Trim(); + if (prefix.Length == 0) + return items; + + return items + .Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticles), prefix, CompareOptions.IgnoreCase)) + .ToList(); + } + + private static List Slice(List items, int startIndex, int limit) + { + startIndex = Math.Clamp(startIndex, 0, items.Count); + if (limit > 0) + return items.Skip(startIndex).Take(limit).ToList(); + return startIndex > 0 ? items.Skip(startIndex).ToList() : items; + } + + private static string NormalizeSortText(string value, string[] titleArticles, CultureInfo culture) + => PadNumberSequences(StripArticle(value, titleArticles)).ToLower(culture); + + private static string StripArticle(string value, string[] titleArticles) + { + var text = (value ?? string.Empty).Trim(); + foreach (var article in titleArticles) + { + var word = article.Trim(); + if (word.Length == 0 || text.Length <= word.Length) + continue; + if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase)) + return text[(word.Length + 1)..].TrimStart(); + } + + return text; + } + + private static string PadNumberSequences(string input) + { + if (!input.Any(char.IsDigit)) + return input; + + var sb = new StringBuilder(input.Length + 8); + for (var i = 0; i < input.Length;) + { + if (!char.IsDigit(input[i])) + { + sb.Append(input[i++]); + continue; + } + + var start = i; + while (i < input.Length && char.IsDigit(input[i])) + i++; + var len = i - start; + if (len < 10) + sb.Append('0', 10 - len); + sb.Append(input, start, len); + } + + return sb.ToString(); + } + + private static string GetString(JsonObject item, string property) + => item.TryGetPropertyValue(property, out var node) && node is not null ? node.GetValueKind() == System.Text.Json.JsonValueKind.String ? node.GetValue() : node.ToString() : string.Empty; + + private static bool TryGetNumber(JsonObject item, string property, out double value) + { + value = 0; + if (!item.TryGetPropertyValue(property, out var node) || node is null) + return false; + + if (node is JsonValue jsonValue) + { + if (jsonValue.TryGetValue(out value)) + return true; + if (jsonValue.TryGetValue(out var b)) + { + value = b ? 1 : 0; + return true; + } + if (jsonValue.TryGetValue(out var s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) + return true; + } + + return double.TryParse(node.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out value); + } + + private static bool TryGetDate(JsonObject item, string property, out double value) + { + value = 0; + var raw = GetString(item, property); + if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + return false; + + value = date.UtcTicks; + return true; + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/ProviderCatalog.cs b/src/Jellyfin.Plugin.Multilang/Services/ProviderCatalog.cs new file mode 100644 index 0000000..67d6df3 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ProviderCatalog.cs @@ -0,0 +1,25 @@ +namespace Jellyfin.Plugin.Multilang.Services; + +public sealed record ProviderInfo( + string Id, + string Name, + bool SupportsMetadata, + bool SupportsArtwork, + string ApiKeyField, + string ApiKeyPlaceholder); + +public sealed class ProviderCatalog +{ + private static readonly ProviderInfo[] Items = + [ + new("tmdb", "TMDb", SupportsMetadata: true, SupportsArtwork: true, "TmdbApiKey", "Mandatory API key"), + new("fanart", "Fanart.tv", SupportsMetadata: false, SupportsArtwork: true, "FanartApiKey", "Optional API key") + ]; + + private readonly Dictionary _byId = + Items.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Providers => Items; + + public bool IsKnown(string id) => _byId.ContainsKey(id); +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/Providers/FanartClient.cs b/src/Jellyfin.Plugin.Multilang/Services/Providers/FanartClient.cs new file mode 100644 index 0000000..9aefb69 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Providers/FanartClient.cs @@ -0,0 +1,100 @@ +namespace Jellyfin.Plugin.Multilang.Services.Providers; + +public sealed class FanartClient +{ + private readonly IHttpClientFactory _httpClientFactory; + + public FanartClient(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public async Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(apiKey)) + return false; + + var http = _httpClientFactory.CreateClient(); + var url = $"https://webservice.fanart.tv/v3/movies/550?api_key={Uri.EscapeDataString(apiKey)}"; + using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; + } + + public async Task FetchMovieImagesAsync( + string tmdbId, + string apiKey, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(tmdbId) || string.IsNullOrWhiteSpace(apiKey)) + return null; + + var http = _httpClientFactory.CreateClient(); + var url = $"https://webservice.fanart.tv/v3/movies/{Uri.EscapeDataString(tmdbId)}?api_key={Uri.EscapeDataString(apiKey)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var doc = await System.Text.Json.JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + var posters = Simplify(Extract(doc.RootElement, "movieposter")); + var logos = Simplify(Extract(doc.RootElement, "hdmovielogo")); + foreach (var kv in Simplify(Extract(doc.RootElement, "movielogo"))) + { + if (!logos.ContainsKey(kv.Key)) + logos[kv.Key] = kv.Value; + } + + return new FanartImages( + Posters: posters, + Logos: logos, + Banners: Simplify(Extract(doc.RootElement, "moviebanner")), + Thumbs: Simplify(Extract(doc.RootElement, "moviethumb"))); + } + + private static Dictionary Extract(System.Text.Json.JsonElement root, string property) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!root.TryGetProperty(property, out var value) || value.ValueKind != System.Text.Json.JsonValueKind.Array) + return result; + + foreach (var image in value.EnumerateArray()) + { + if (image.ValueKind != System.Text.Json.JsonValueKind.Object || + !image.TryGetProperty("lang", out var langElement) || + langElement.ValueKind != System.Text.Json.JsonValueKind.String || + !image.TryGetProperty("url", out var urlElement) || + urlElement.ValueKind != System.Text.Json.JsonValueKind.String) + continue; + + var lang = (langElement.GetString() ?? string.Empty).Trim(); + var imageUrl = (urlElement.GetString() ?? string.Empty).Trim(); + if (lang.Length == 0 || lang.Equals("00", StringComparison.OrdinalIgnoreCase) || imageUrl.Length == 0) + continue; + + var likes = 0; + if (image.TryGetProperty("likes", out var likesElement)) + { + if (likesElement.ValueKind == System.Text.Json.JsonValueKind.Number) + likesElement.TryGetInt32(out likes); + else if (likesElement.ValueKind == System.Text.Json.JsonValueKind.String) + int.TryParse(likesElement.GetString(), out likes); + } + + if (!result.TryGetValue(lang, out var current) || likes > current.Likes) + result[lang] = (imageUrl, likes); + } + + return result; + } + + private static Dictionary Simplify(Dictionary images) + => images.ToDictionary(k => k.Key, v => v.Value.Url, StringComparer.OrdinalIgnoreCase); +} + +public sealed record FanartImages( + Dictionary Posters, + Dictionary Logos, + Dictionary Banners, + Dictionary Thumbs); diff --git a/src/Jellyfin.Plugin.Multilang/Services/Providers/FetchRateLimiter.cs b/src/Jellyfin.Plugin.Multilang/Services/Providers/FetchRateLimiter.cs new file mode 100644 index 0000000..4070464 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Providers/FetchRateLimiter.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; + +namespace Jellyfin.Plugin.Multilang.Services.Providers; + +public sealed class FetchRateLimiter +{ + private readonly TimeSpan _minimumSpacing; + private long _lastStartTicks; + + public FetchRateLimiter(TimeSpan minimumSpacing) + { + _minimumSpacing = minimumSpacing; + } + + public async Task WaitAsync(CancellationToken cancellationToken) + { + var last = Interlocked.Read(ref _lastStartTicks); + if (last != 0) + { + var elapsed = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - last) / (double)Stopwatch.Frequency); + var remaining = _minimumSpacing - elapsed; + if (remaining > TimeSpan.Zero) + await Task.Delay(remaining, cancellationToken).ConfigureAwait(false); + } + + Interlocked.Exchange(ref _lastStartTicks, Stopwatch.GetTimestamp()); + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/Providers/ProviderHttp.cs b/src/Jellyfin.Plugin.Multilang/Services/Providers/ProviderHttp.cs new file mode 100644 index 0000000..6ce9fca --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Providers/ProviderHttp.cs @@ -0,0 +1,141 @@ +using System.Net; + +namespace Jellyfin.Plugin.Multilang.Services.Providers; + +public enum ProviderFailureKind +{ + Retryable, + Terminal +} + +public sealed record ProviderHttpDiagnostics( + ProviderHttpCount[] Counts, + ProviderHttpRecent[] Recent); + +public sealed record ProviderHttpCount( + string Provider, + string Outcome, + int Count); + +public sealed record ProviderHttpRecent( + string Provider, + string Url, + string Outcome, + int Attempt, + long At); + +public static class ProviderHttp +{ + private static readonly object DiagnosticsLock = new(); + private static readonly Dictionary Counts = new(StringComparer.OrdinalIgnoreCase); + private static readonly Queue Recent = new(); + + public static ProviderFailureKind Classify(HttpStatusCode code) + => code is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout || + (int)code >= 500 + ? ProviderFailureKind.Retryable + : ProviderFailureKind.Terminal; + + public static async Task SendWithRetryAsync( + HttpClient http, + HttpRequestMessage request, + CancellationToken cancellationToken) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var response = await http.SendAsync(Clone(request), cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts) + { + Record(request, "HTTP " + (int)response.StatusCode, attempt); + return response; + } + + var delay = GetRetryDelay(response, attempt); + response.Dispose(); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (attempt >= maxAttempts) + { + Record(request, ex.GetType().Name, attempt); + throw; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false); + } + } + + throw new HttpRequestException("Provider request failed after retries."); + } + + public static ProviderHttpDiagnostics GetDiagnostics() + { + lock (DiagnosticsLock) + { + return new ProviderHttpDiagnostics( + Counts + .Select(kvp => + { + var index = kvp.Key.IndexOf('|', StringComparison.Ordinal); + var provider = index >= 0 ? kvp.Key[..index] : kvp.Key; + var outcome = index >= 0 ? kvp.Key[(index + 1)..] : string.Empty; + return new ProviderHttpCount(provider, outcome, kvp.Value); + }) + .OrderBy(c => c.Provider) + .ThenBy(c => c.Outcome) + .ToArray(), + Recent.Reverse().ToArray()); + } + } + + private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) + => response.Headers.RetryAfter?.Delta ?? + (response.Headers.RetryAfter?.Date is { } date + ? date - DateTimeOffset.UtcNow + : TimeSpan.FromMilliseconds(250 * attempt)); + + private static void Record(HttpRequestMessage request, string outcome, int attempt) + { + var provider = ProviderName(request.RequestUri); + var key = provider + "|" + outcome; + var recent = new ProviderHttpRecent( + provider, + SafeUrl(request.RequestUri), + outcome, + attempt, + DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + lock (DiagnosticsLock) + { + Counts[key] = Counts.TryGetValue(key, out var count) ? count + 1 : 1; + Recent.Enqueue(recent); + while (Recent.Count > 50) + Recent.Dequeue(); + } + } + + private static string ProviderName(Uri? uri) + { + var host = uri?.Host ?? string.Empty; + if (host.Contains("themoviedb", StringComparison.OrdinalIgnoreCase) || host.Contains("tmdb", StringComparison.OrdinalIgnoreCase)) + return "TMDb"; + if (host.Contains("fanart", StringComparison.OrdinalIgnoreCase)) + return "Fanart.tv"; + return string.IsNullOrWhiteSpace(host) ? "unknown" : host; + } + + private static string SafeUrl(Uri? uri) + => uri is null ? string.Empty : uri.GetLeftPart(UriPartial.Path); + + private static HttpRequestMessage Clone(HttpRequestMessage request) + { + var clone = new HttpRequestMessage(request.Method, request.RequestUri); + foreach (var header in request.Headers) + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + return clone; + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/Providers/TmdbClient.cs b/src/Jellyfin.Plugin.Multilang/Services/Providers/TmdbClient.cs new file mode 100644 index 0000000..3c59bdf --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Providers/TmdbClient.cs @@ -0,0 +1,201 @@ +using System.Net; +using System.Text.Json; +using Jellyfin.Plugin.Multilang.Services.Refresh; + +namespace Jellyfin.Plugin.Multilang.Services.Providers; + +public sealed class TmdbClient +{ + private readonly IHttpClientFactory _httpClientFactory; + + public TmdbClient(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public async Task FetchMetadataAsync( + RefreshItemInfo item, + string language, + string apiKey, + FetchRateLimiter rateLimiter, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException("TMDb API key is required."); + + await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); + var http = _httpClientFactory.CreateClient(); + var url = $"https://api.themoviedb.org/3/{BuildPath(item)}?api_key={Uri.EscapeDataString(apiKey)}&language={Uri.EscapeDataString(language)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + return new TmdbMetadata( + Title: ExtractTitle(doc.RootElement, item.Kind), + Overview: ExtractString(doc.RootElement, "overview"), + Tagline: ExtractString(doc.RootElement, "tagline"), + OriginalTitle: ExtractOriginalTitle(doc.RootElement, item.Kind), + OriginalLanguage: ExtractString(doc.RootElement, "original_language"), + OriginCountries: ExtractStringArray(doc.RootElement, "origin_country"), + ProductionCountries: ExtractIsoFromObjectArray(doc.RootElement, "production_countries", "iso_3166_1"), + SpokenLanguages: ExtractIsoFromObjectArray(doc.RootElement, "spoken_languages", "iso_639_1"), + Genres: ExtractGenres(doc.RootElement)); + } + + public async Task FetchImagesAsync( + RefreshItemInfo item, + string apiKey, + FetchRateLimiter rateLimiter, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException("TMDb API key is required."); + + if (item.Kind is "tvepisode") + return new TmdbImages([], [], []); + + await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); + var http = _httpClientFactory.CreateClient(); + var url = $"https://api.themoviedb.org/3/{BuildPath(item)}/images?api_key={Uri.EscapeDataString(apiKey)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + return new TmdbImages( + Posters: ExtractImageUrls(doc.RootElement, "posters"), + Logos: ExtractImageUrls(doc.RootElement, "logos"), + Backdrops: ExtractImageUrls(doc.RootElement, "backdrops")); + } + + private static string BuildPath(RefreshItemInfo item) + => item.Kind switch + { + "tvseason" => $"tv/{item.TmdbId}/season/{item.SeasonNumber}", + "tvepisode" => $"tv/{item.TmdbId}/season/{item.SeasonNumber}/episode/{item.EpisodeNumber}", + _ => $"{item.Kind}/{item.TmdbId}" + }; + + private static string ExtractTitle(JsonElement root, string kind) + => kind is "tv" or "collection" or "tvseason" or "tvepisode" + ? ExtractString(root, "name") + : ExtractString(root, "title"); + + private static string ExtractOriginalTitle(JsonElement root, string kind) + => kind switch + { + "tv" => ExtractString(root, "original_name"), + "movie" => ExtractString(root, "original_title"), + "collection" => FirstNonEmpty(ExtractString(root, "original_name"), ExtractString(root, "name")), + "tvseason" => ExtractString(root, "name"), + "tvepisode" => ExtractString(root, "name"), + _ => ExtractString(root, "original_title") + }; + + private static string ExtractString(JsonElement root, string property) + => root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : string.Empty; + + private static string[] ExtractStringArray(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) + return []; + + return value.EnumerateArray() + .Where(el => el.ValueKind == JsonValueKind.String) + .Select(el => el.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static string[] ExtractIsoFromObjectArray(JsonElement root, string property, string isoProperty) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) + return []; + + return value.EnumerateArray() + .Where(el => el.ValueKind == JsonValueKind.Object) + .Select(el => el.TryGetProperty(isoProperty, out var iso) && iso.ValueKind == JsonValueKind.String ? iso.GetString() ?? string.Empty : string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static TmdbGenre[] ExtractGenres(JsonElement root) + { + if (!root.TryGetProperty("genres", out var value) || value.ValueKind != JsonValueKind.Array) + return []; + + return value.EnumerateArray() + .Where(el => el.ValueKind == JsonValueKind.Object && el.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number) + .Select(el => new TmdbGenre( + el.GetProperty("id").GetInt32(), + el.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty)) + .Where(g => g.Id > 0 && !string.IsNullOrWhiteSpace(g.Name)) + .GroupBy(g => g.Id) + .Select(g => g.First()) + .ToArray(); + } + + private static Dictionary ExtractImageUrls(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var image in value.EnumerateArray()) + { + if (image.ValueKind != JsonValueKind.Object || + !image.TryGetProperty("file_path", out var pathElement) || + pathElement.ValueKind != JsonValueKind.String) + continue; + + var path = pathElement.GetString(); + if (string.IsNullOrWhiteSpace(path)) + continue; + + var iso = image.TryGetProperty("iso_639_1", out var isoElement) && isoElement.ValueKind == JsonValueKind.String + ? isoElement.GetString() ?? string.Empty + : string.Empty; + if (string.IsNullOrWhiteSpace(iso) || result.ContainsKey(iso)) + continue; + + result[iso] = "https://image.tmdb.org/t/p/original" + path; + } + + return result; + } + + private static string FirstNonEmpty(params string[] values) + => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty; +} + +public sealed record TmdbMetadata( + string Title, + string Overview, + string Tagline, + string OriginalTitle, + string OriginalLanguage, + string[] OriginCountries, + string[] ProductionCountries, + string[] SpokenLanguages, + TmdbGenre[] Genres) +{ + public int[] GenreIds { get; } = Genres.Select(g => g.Id).Distinct().ToArray(); +} + +public sealed record TmdbGenre(int Id, string Name); + +public sealed record TmdbImages( + Dictionary Posters, + Dictionary Logos, + Dictionary Backdrops); diff --git a/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshCoordinator.cs b/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshCoordinator.cs new file mode 100644 index 0000000..24261fa --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshCoordinator.cs @@ -0,0 +1,181 @@ +using Jellyfin.Plugin.Multilang.Services; +using System.Diagnostics; + +namespace Jellyfin.Plugin.Multilang.Services.Refresh; + +public sealed class RefreshCoordinator +{ + private sealed class QueueEntry + { + public required string ItemId { get; init; } + + public required RefreshSourceTier SourceTier { get; set; } + + public required RefreshWorkClass WorkClass { get; init; } + + public required RefreshJobType JobType { get; set; } + + public required Func> Work { get; set; } + + public required TaskCompletionSource Completion { get; init; } + + public long Sequence { get; init; } + } + + private readonly object _lock = new(); + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private readonly Queue _recent = new(); + private readonly SemaphoreSlim _signal = new(0, int.MaxValue); + private readonly ItemsProxyCache _itemsProxyCache; + private long _sequence; + private QueueEntry? _active; + + public SemaphoreSlim ScanMutex { get; } = new(1, 1); + + public RefreshCoordinator(ItemsProxyCache itemsProxyCache) + { + _itemsProxyCache = itemsProxyCache; + _ = Task.Run(WorkerLoop); + } + + public Task EnqueueAsync( + string itemId, + RefreshSourceTier sourceTier, + RefreshWorkClass workClass, + RefreshJobType jobType, + Func> work, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(itemId)) + return Task.FromResult(false); + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + lock (_lock) + { + if (_entries.TryGetValue(itemId, out var existing)) + { + if (jobType > existing.JobType) + { + existing.JobType = jobType; + existing.Work = work; + } + + if (sourceTier > existing.SourceTier) + existing.SourceTier = sourceTier; + + return existing.Completion.Task; + } + + var entry = new QueueEntry + { + ItemId = itemId, + SourceTier = sourceTier, + WorkClass = workClass, + JobType = jobType, + Work = work, + Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + Sequence = ++_sequence + }; + + _entries.Add(itemId, entry); + _signal.Release(); + return entry.Completion.Task; + } + } + + private async Task WorkerLoop() + { + while (true) + { + await _signal.WaitAsync().ConfigureAwait(false); + + QueueEntry? entry; + lock (_lock) + entry = DequeueLocked(); + + if (entry is null) + continue; + + var sw = Stopwatch.StartNew(); + var ok = false; + var error = string.Empty; + try + { + lock (_lock) + _active = entry; + + ok = await entry.Work(entry.JobType, CancellationToken.None).ConfigureAwait(false); + if (ok) + _itemsProxyCache.InvalidateItems([entry.ItemId]); + entry.Completion.TrySetResult(ok); + } + catch (Exception ex) + { + error = ex.Message; + entry.Completion.TrySetException(ex); + } + finally + { + sw.Stop(); + lock (_lock) + { + if (ReferenceEquals(_active, entry)) + _active = null; + _entries.Remove(entry.ItemId); + _recent.Enqueue(new RefreshRecentItem( + entry.ItemId, + entry.SourceTier.ToString(), + entry.WorkClass.ToString(), + entry.JobType.ToString(), + ok, + sw.ElapsedMilliseconds, + error, + DateTimeOffset.UtcNow.ToUnixTimeSeconds())); + while (_recent.Count > 50) + _recent.Dequeue(); + } + } + } + } + + private QueueEntry? DequeueLocked() + { + if (_entries.Count == 0) + return null; + + return _entries.Values + .OrderByDescending(e => e.SourceTier) + .ThenBy(e => e.WorkClass) + .ThenBy(e => e.Sequence) + .First(); + } + + public RefreshQueueDiagnostics GetDiagnostics() + { + lock (_lock) + { + var queued = _entries.Values + .Where(e => !ReferenceEquals(e, _active)) + .OrderByDescending(e => e.SourceTier) + .ThenBy(e => e.WorkClass) + .ThenBy(e => e.Sequence) + .Select(ToDiagnostics) + .ToArray(); + + return new RefreshQueueDiagnostics( + queued.Length, + queued, + _active is null ? null : ToDiagnostics(_active), + _recent.Reverse().ToArray()); + } + } + + private static RefreshQueueEntry ToDiagnostics(QueueEntry entry) + => new( + entry.ItemId, + entry.SourceTier.ToString(), + entry.WorkClass.ToString(), + entry.JobType.ToString(), + entry.Sequence); +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshModels.cs b/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshModels.cs new file mode 100644 index 0000000..7c76f97 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshModels.cs @@ -0,0 +1,85 @@ +namespace Jellyfin.Plugin.Multilang.Services.Refresh; + +public enum RefreshJobType +{ + Aggregate = 0, + Missing = 1, + Full = 2 +} + +public enum RefreshSourceTier +{ + Background = 0, + Manual = 1, + OnTheFly = 2 +} + +public enum RefreshWorkClass +{ + Movies = 0, + Collections = 1, + Episodes = 2, + Seasons = 3, + Series = 4 +} + +public sealed record RefreshItemInfo( + string ItemId, + string TmdbId, + string Kind, + RefreshWorkClass WorkClass, + int SeasonNumber, + int EpisodeNumber, + string? AudioLanguage, + string DisplayName); + +public sealed record RefreshQueueEntry( + string ItemId, + string SourceTier, + string WorkClass, + string JobType, + long Sequence); + +public sealed record RefreshRecentItem( + string ItemId, + string SourceTier, + string WorkClass, + string JobType, + bool Ok, + long DurationMs, + string Error, + long FinishedAt); + +public sealed record RefreshQueueDiagnostics( + int QueuedCount, + RefreshQueueEntry[] Queued, + RefreshQueueEntry? Active, + RefreshRecentItem[] Recent); + +public sealed record RefreshScanDiagnostics( + bool Running, + string Mode, + string Source, + long StartedAt, + long FinishedAt, + long ElapsedMs, + int Items, + int Due, + int Refreshed, + int SkippedNotDue, + int SkippedNoData, + int New, + int Deleted, + int CurrentIndex, + string CurrentItemId, + string CurrentName, + string CurrentKind, + string CurrentAction, + bool Success, + string Error); + +public sealed record RefreshDiagnostics( + long LastScanStarted, + RefreshScanDiagnostics Scan, + RefreshQueueDiagnostics Queue, + object ProviderHttp); diff --git a/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshService.cs b/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshService.cs new file mode 100644 index 0000000..9bfc920 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshService.cs @@ -0,0 +1,748 @@ +using System.Diagnostics; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.Multilang.Configuration; +using Jellyfin.Plugin.Multilang.Data; +using Jellyfin.Plugin.Multilang.Services.Providers; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Multilang.Services.Refresh; + +public sealed class RefreshService +{ + private const string OriginalLanguageTag = "Original"; + + private readonly ILibraryManager _libraryManager; + private readonly TranslationStore _store; + private readonly TmdbClient _tmdbClient; + private readonly FanartClient _fanartClient; + private readonly RefreshCoordinator _coordinator; + private readonly ILogger _logger; + private readonly object _diagnosticsLock = new(); + private RefreshScanDiagnostics _scanDiagnostics = EmptyScanDiagnostics(); + + public RefreshService( + ILibraryManager libraryManager, + TranslationStore store, + TmdbClient tmdbClient, + FanartClient fanartClient, + RefreshCoordinator coordinator, + ILogger logger) + { + _libraryManager = libraryManager; + _store = store; + _tmdbClient = tmdbClient; + _fanartClient = fanartClient; + _coordinator = coordinator; + _logger = logger; + } + + public RefreshDiagnostics GetDiagnostics() + => new( + _store.GetLastScanStarted(), + GetScanDiagnostics(), + _coordinator.GetDiagnostics(), + ProviderHttp.GetDiagnostics()); + + private RefreshScanDiagnostics GetScanDiagnostics() + { + lock (_diagnosticsLock) + return _scanDiagnostics; + } + + private void SetScanDiagnostics(RefreshScanDiagnostics diagnostics) + { + lock (_diagnosticsLock) + _scanDiagnostics = diagnostics; + } + + private static RefreshScanDiagnostics EmptyScanDiagnostics() + => new(false, string.Empty, string.Empty, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, string.Empty, string.Empty, string.Empty, string.Empty, true, string.Empty); + + public async Task RunScanAsync(RefreshJobType jobType, IProgress progress, CancellationToken cancellationToken, string runSource) + { + var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable."); + ValidateRefreshConfiguration(cfg); + await _coordinator.ScanMutex.WaitAsync(cancellationToken).ConfigureAwait(false); + var startedAt = TranslationStore.NowUnixUtc(); + var sw = Stopwatch.StartNew(); + var mode = jobType.ToString(); + var source = runSource ?? string.Empty; + var itemCount = 0; + var dueCount = 0; + var refreshedCount = 0; + var skippedNotDueCount = 0; + var skippedNoDataCount = 0; + var newCount = 0; + var deletedCount = 0; + var currentIndex = 0; + var currentItemId = string.Empty; + var currentName = string.Empty; + var currentKind = string.Empty; + var currentAction = "initializing"; + + void UpdateRunningScan(string action = "") + { + if (!string.IsNullOrWhiteSpace(action)) + currentAction = action; + + SetScanDiagnostics(new RefreshScanDiagnostics( + true, + mode, + source, + startedAt, + 0, + sw.ElapsedMilliseconds, + itemCount, + dueCount, + refreshedCount, + skippedNotDueCount, + skippedNoDataCount, + newCount, + deletedCount, + currentIndex, + currentItemId, + currentName, + currentKind, + currentAction, + true, + string.Empty)); + } + + void SetCurrent(RefreshItemInfo item, int index, string action) + { + currentIndex = index; + currentItemId = item.ItemId; + currentName = item.DisplayName; + currentKind = item.Kind; + currentAction = action; + } + + void CompleteScan(bool success, string error) + => SetScanDiagnostics(new RefreshScanDiagnostics( + false, + mode, + source, + startedAt, + TranslationStore.NowUnixUtc(), + sw.ElapsedMilliseconds, + itemCount, + dueCount, + refreshedCount, + skippedNotDueCount, + skippedNoDataCount, + newCount, + deletedCount, + currentIndex, + currentItemId, + currentName, + currentKind, + success ? "complete" : "failed", + success, + error)); + + try + { + UpdateRunningScan(); + var items = GetAllItemsWithTmdbId(); + var now = TranslationStore.NowUnixUtc(); + var missingCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForMissingData)).TotalSeconds; + var fullCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForExistingData)).TotalSeconds; + var ordered = items.OrderBy(i => i.WorkClass).ToList(); + itemCount = ordered.Count; + var liveIds = ordered.Select(i => i.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var trackedBefore = _store.GetTrackedItemIds(); + deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count(); + newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count(); + _store.CleanupNotInLibrary(liveIds); + + _logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount); + var index = 0; + UpdateRunningScan(); + foreach (var item in ordered) + { + cancellationToken.ThrowIfCancellationRequested(); + index++; + SetCurrent(item, index, "checking"); + var status = _store.GetFactsStatus(item.ItemId); + var isNew = !status.Exists; + var due = jobType == RefreshJobType.Full + ? !status.Exists || status.FullCheckedAt < fullCutoff + : !status.Exists || status.MissingCheckedAt < missingCutoff; + + if (!due) + { + skippedNotDueCount++; + if (cfg.VerboseLogging && cfg.EnableLogging) + { + _logger.LogInformation( + "Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due", + index, + ordered.Count, + item.ItemId, + item.DisplayName, + item.Kind); + } + + progress.Report(index * 100.0 / Math.Max(1, ordered.Count)); + UpdateRunningScan("skipped-not-due"); + continue; + } + + var scanIndex = index; + var actualJobType = jobType == RefreshJobType.Full || !status.Exists ? RefreshJobType.Full : RefreshJobType.Missing; + dueCount++; + SetCurrent(item, scanIndex, "fetching"); + Log( + cfg.EnableLogging, + LogLevel.Information, + "Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId} mode={Mode} new={New}", + scanIndex, + ordered.Count, + item.ItemId, + item.DisplayName, + item.Kind, + item.TmdbId, + actualJobType, + isNew); + + var ok = await _coordinator.EnqueueAsync( + item.ItemId, + RefreshSourceTier.Background, + item.WorkClass, + actualJobType, + async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, ordered.Count, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + if (ok) + refreshedCount++; + else + skippedNoDataCount++; + + progress.Report(index * 100.0 / Math.Max(1, ordered.Count)); + UpdateRunningScan(ok ? "refreshed" : "skipped-no-data"); + } + + sw.Stop(); + _store.SetLastScanStarted(now); + CompleteScan(true, string.Empty); + _logger.LogInformation( + "Multilang refresh complete mode={Mode} source={Source} elapsed={ElapsedMs}ms items={Items} due={Due} refreshed={Refreshed} skippedNotDue={SkippedNotDue} skippedNoData={SkippedNoData} new={New} deleted={Deleted}", + jobType, + runSource, + sw.ElapsedMilliseconds, + ordered.Count, + dueCount, + refreshedCount, + skippedNotDueCount, + skippedNoDataCount, + newCount, + deletedCount); + } + catch (Exception ex) + { + sw.Stop(); + CompleteScan(false, ex.Message); + throw; + } + finally + { + _coordinator.ScanMutex.Release(); + } + } + + public async Task RefreshItemAsync(Guid itemId, bool includeChildren, CancellationToken cancellationToken) + { + var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable."); + ValidateRefreshConfiguration(cfg); + var root = _libraryManager.GetItemById(itemId); + if (root is null) + { + Log(cfg.EnableLogging, LogLevel.Warning, "Manual refresh requested for missing item {ItemId}", itemId); + return; + } + + var items = BuildItemInfos(GetItemAndChildren(root, includeChildren)).ToList(); + var total = items.Count; + var index = 0; + _logger.LogInformation("Manual Multilang refresh started item={ItemId} name={Name} items={Items} includeChildren={IncludeChildren}", itemId, root.Name ?? string.Empty, total, includeChildren); + foreach (var item in items) + { + index++; + var scanIndex = index; + Log( + cfg.EnableLogging, + LogLevel.Information, + "Manual Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", + scanIndex, + total, + item.ItemId, + item.DisplayName, + item.Kind, + item.TmdbId); + + var ok = await _coordinator.EnqueueAsync( + item.ItemId, + RefreshSourceTier.Manual, + item.WorkClass, + RefreshJobType.Full, + async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, total, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + Log( + cfg.EnableLogging, + ok ? LogLevel.Information : LogLevel.Warning, + "Manual Multilang refresh item {Status} [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind}", + ok ? "complete" : "skipped", + scanIndex, + total, + item.ItemId, + item.DisplayName, + item.Kind); + } + } + + public bool EnqueueOnTheFlyMissing(IEnumerable itemIds) + { + var cfg = Plugin.Instance?.Configuration; + if (cfg is null) + return false; + try + { + ValidateRefreshConfiguration(cfg); + } + catch (InvalidOperationException) + { + return false; + } + + var ids = itemIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (ids.Length == 0) + return false; + + var existingFacts = _store.GetExistingFactsItemIds(ids); + var queuedAny = false; + var sourceTier = ids.Length <= 5 ? RefreshSourceTier.OnTheFly : RefreshSourceTier.Background; + foreach (var itemId in ids) + { + if (existingFacts.Contains(itemId)) + continue; + if (!Guid.TryParseExact(itemId, "N", out var guid)) + continue; + + var item = _libraryManager.GetItemById(guid); + if (item is null) + continue; + + var info = BuildItemInfos([item]).FirstOrDefault(); + if (info is null) + continue; + + queuedAny = true; + _ = _coordinator.EnqueueAsync( + info.ItemId, + sourceTier, + info.WorkClass, + RefreshJobType.Full, + async (actualJobType, ct) => await RefreshItemCoreAsync(info, cfg, actualJobType, 0, 0, ct).ConfigureAwait(false), + CancellationToken.None) + .ContinueWith( + task => + { + if (task.Exception is not null) + _logger.LogWarning(task.Exception, "On-demand Multilang refresh failed for item={ItemId}", info.ItemId); + }, + TaskContinuationOptions.OnlyOnFaulted); + _logger.LogInformation("Queued on-demand Multilang refresh item={ItemId} tier={Tier}", info.ItemId, sourceTier); + } + + return queuedAny; + } + + public RefreshItemInfo? GetRefreshItemInfo(Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + return item is null ? null : BuildItemInfos([item]).FirstOrDefault(); + } + + private async Task RefreshItemCoreAsync( + RefreshItemInfo item, + PluginConfiguration cfg, + RefreshJobType jobType, + int scanIndex, + int totalItems, + CancellationToken cancellationToken) + { + var langs = NormalizeLanguages(cfg.Languages); + if (langs.Length == 0) + throw new InvalidOperationException("No Multilang languages are configured."); + + if (cfg.VerboseLogging && cfg.EnableLogging) + _logger.LogInformation("[{Index}/{Total}] Fetching item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.Kind, item.TmdbId); + + var now = TranslationStore.NowUnixUtc(); + var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(250)); + var wroteAny = false; + TmdbMetadata? firstMeta = null; + var fetchedMetadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var lang in langs) + { + var meta = await _tmdbClient.FetchMetadataAsync(item, lang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false); + if (meta is null) + { + Log(cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb 404, skipping item={ItemId} name=\"{Name}\" tmdb={TmdbId} kind={Kind}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.TmdbId, item.Kind); + _store.MarkFactsChecked(item, now, jobType == RefreshJobType.Full ? now : 0); + return false; + } + + Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb metadata ok item={ItemId} lang={Lang}", scanIndex, totalItems, item.ItemId, lang); + _store.UpsertTranslation(item.ItemId, lang, "title", meta.Title); + _store.UpsertTranslation(item.ItemId, lang, "overview", meta.Overview); + _store.UpsertTranslation(item.ItemId, lang, "tagline", meta.Tagline); + StoreGenres(item, lang, meta); + fetchedMetadata[lang] = meta; + firstMeta ??= meta; + if (!wroteAny) + { + _store.UpsertFacts(item, meta, now, jobType == RefreshJobType.Full ? now : 0); + wroteAny = true; + } + } + + var artworkLanguages = langs; + if (firstMeta is not null && !string.IsNullOrWhiteSpace(firstMeta.OriginalLanguage)) + { + var originalLang = firstMeta.OriginalLanguage.Trim(); + var existingLang = langs.FirstOrDefault(lang => SameLanguageBase(lang, originalLang)); + Log( + cfg.EnableLogging, + LogLevel.Information, + "[{Index}/{Total}] Original language item={ItemId} lang={OriginalLang} metadataSource={Source}", + scanIndex, + totalItems, + item.ItemId, + originalLang, + existingLang is not null ? "configured-language:" + existingLang : "extra-fetch"); + var originalMeta = existingLang is not null + ? fetchedMetadata[existingLang] + : await _tmdbClient.FetchMetadataAsync(item, originalLang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false); + + if (originalMeta is not null) + { + _store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "title", originalMeta.Title); + _store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "overview", originalMeta.Overview); + _store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "tagline", originalMeta.Tagline); + StoreGenres(item, OriginalLanguageTag, originalMeta); + Log( + cfg.VerboseLogging && cfg.EnableLogging, + LogLevel.Information, + "[{Index}/{Total}] Original metadata stored item={ItemId} lang={OriginalLang} titlePresent={TitlePresent} overviewPresent={OverviewPresent} taglinePresent={TaglinePresent}", + scanIndex, + totalItems, + item.ItemId, + originalLang, + !string.IsNullOrWhiteSpace(originalMeta.Title), + !string.IsNullOrWhiteSpace(originalMeta.Overview), + !string.IsNullOrWhiteSpace(originalMeta.Tagline)); + } + else + { + Log( + cfg.EnableLogging, + LogLevel.Warning, + "[{Index}/{Total}] Original metadata unavailable item={ItemId} lang={OriginalLang}", + scanIndex, + totalItems, + item.ItemId, + originalLang); + } + + artworkLanguages = AppendOriginalLanguage(langs, originalLang); + Log( + cfg.VerboseLogging && cfg.EnableLogging, + LogLevel.Information, + "[{Index}/{Total}] Artwork language set item={ItemId} langs={Languages}", + scanIndex, + totalItems, + item.ItemId, + string.Join(",", artworkLanguages)); + } + + await StoreArtworkAsync(item, cfg, artworkLanguages, limiter, now, cancellationToken).ConfigureAwait(false); + + Log(cfg.EnableLogging, LogLevel.Information, "Multilang refresh item complete item={ItemId} kind={Kind} tmdb={TmdbId} mode={Mode}", item.ItemId, item.Kind, item.TmdbId, jobType); + return true; + } + + private void StoreGenres(RefreshItemInfo item, string lang, TmdbMetadata metadata) + { + var media = GenreMediaFor(item.Kind); + if (media is null) + return; + + foreach (var genre in metadata.Genres) + _store.UpsertGenre(genre.Id, media, lang, genre.Name); + } + + private static string? GenreMediaFor(string kind) + => kind.Equals("movie", StringComparison.OrdinalIgnoreCase) || + kind.Equals("collection", StringComparison.OrdinalIgnoreCase) + ? "movie" + : kind.Equals("tv", StringComparison.OrdinalIgnoreCase) || + kind.Equals("tvseason", StringComparison.OrdinalIgnoreCase) || + kind.Equals("tvepisode", StringComparison.OrdinalIgnoreCase) + ? "tv" + : null; + + private async Task StoreArtworkAsync( + RefreshItemInfo item, + PluginConfiguration cfg, + string[] languages, + FetchRateLimiter limiter, + long updatedAt, + CancellationToken cancellationToken) + { + if (item.Kind is "tvepisode") + return; + + var providers = cfg.Providers + .Where(p => p.ArtworkOrder >= 0) + .OrderBy(p => p.ArtworkOrder) + .Select(p => p.Id.Trim().ToLowerInvariant()) + .ToArray(); + if (providers.Length == 0) + return; + + TmdbImages? tmdbImages = null; + FanartImages? fanartImages = null; + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var provider in providers) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (provider.Equals("tmdb", StringComparison.OrdinalIgnoreCase)) + { + tmdbImages ??= await _tmdbClient.FetchImagesAsync(item, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false); + if (tmdbImages is null) + continue; + + StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written); + StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written); + StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written); + Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages)); + continue; + } + + if (provider.Equals("fanart", StringComparison.OrdinalIgnoreCase) && item.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase)) + { + fanartImages ??= await _fanartClient.FetchMovieImagesAsync(item.TmdbId, cfg.FanartApiKey, cancellationToken).ConfigureAwait(false); + if (fanartImages is null) + continue; + + StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written); + StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written); + StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written); + StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written); + Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages)); + } + } + } + + private void StoreImageMap( + string itemId, + IEnumerable languages, + string kind, + IReadOnlyDictionary images, + long updatedAt, + HashSet written) + { + foreach (var lang in languages) + { + var storeLang = lang.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase) + ? OriginalLanguageTag + : lang; + var key = kind + ":" + storeLang; + if (written.Contains(key)) + continue; + + var iso = ToIso639(lang); + if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url)) + continue; + + _store.UpsertAsset(itemId, storeLang, kind, url, updatedAt); + written.Add(key); + } + } + + private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage) + => languages + .Concat([OriginalLanguageTag + ":" + originalLanguage]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool SameLanguageBase(string left, string right) + => ToIso639(left).Equals(ToIso639(right), StringComparison.OrdinalIgnoreCase); + + private static string ToIso639(string language) + { + if (language.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase)) + language = language[(OriginalLanguageTag.Length + 1)..]; + var index = language.IndexOf('-', StringComparison.Ordinal); + return (index > 0 ? language[..index] : language).Trim().ToLowerInvariant(); + } + + private List GetAllItemsWithTmdbId() + { + var query = new InternalItemsQuery + { + IncludeItemTypes = + [ + BaseItemKind.Movie, + BaseItemKind.Series, + BaseItemKind.BoxSet, + BaseItemKind.Season, + BaseItemKind.Episode + ], + Recursive = true + }; + + return BuildItemInfos(_libraryManager.GetItemList(query)).ToList(); + } + + private IEnumerable BuildItemInfos(IEnumerable items) + { + foreach (var item in items) + { + var itemId = TranslationStore.ToItemId32(item.Id); + var typeName = item.GetType().Name; + + if (typeName.Equals("Season", StringComparison.OrdinalIgnoreCase)) + { + if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out _)) + yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvseason", RefreshWorkClass.Seasons, seasonNumber, 0, string.Empty, item.Name ?? string.Empty); + continue; + } + + if (typeName.Equals("Episode", StringComparison.OrdinalIgnoreCase)) + { + if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out var episodeNumber)) + yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvepisode", RefreshWorkClass.Episodes, seasonNumber, episodeNumber, string.Empty, item.Name ?? string.Empty); + continue; + } + + var providerIds = item.ProviderIds ?? new Dictionary(); + providerIds.TryGetValue("Tmdb", out var tmdbId); + providerIds.TryGetValue("TmdbCollection", out var tmdbCollectionId); + + if (typeName.Equals("Series", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(tmdbId)) + { + yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "tv", RefreshWorkClass.Series, 0, 0, string.Empty, item.Name ?? string.Empty); + continue; + } + + if (typeName.Equals("BoxSet", StringComparison.OrdinalIgnoreCase)) + { + var collectionId = FirstNonEmpty(tmdbCollectionId, tmdbId); + if (!string.IsNullOrWhiteSpace(collectionId)) + yield return new RefreshItemInfo(itemId, collectionId.Trim(), "collection", RefreshWorkClass.Collections, 0, 0, string.Empty, item.Name ?? string.Empty); + continue; + } + + if (!string.IsNullOrWhiteSpace(tmdbId)) + yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "movie", RefreshWorkClass.Movies, 0, 0, string.Empty, item.Name ?? string.Empty); + } + } + + private List GetItemAndChildren(BaseItem root, bool includeChildren) + { + var items = new List { root }; + if (!includeChildren) + return items; + + items.AddRange(_libraryManager.GetItemList(new InternalItemsQuery + { + ParentId = root.Id, + Recursive = true + })); + return items; + } + + private bool TryResolveEpisodeContext(BaseItem item, out string seriesTmdbId, out int seasonNumber, out int episodeNumber) + { + seriesTmdbId = string.Empty; + seasonNumber = item.ParentIndexNumber ?? 0; + episodeNumber = item.IndexNumber ?? 0; + + var typeName = item.GetType().Name; + var isSeason = typeName.Equals("Season", StringComparison.OrdinalIgnoreCase); + var seasonGuid = !isSeason ? TryGetGuid(item, "SeasonId") ?? (item.ParentId == Guid.Empty ? null : item.ParentId) : null; + var seriesGuid = TryGetGuid(item, "SeriesId") ?? (isSeason && item.ParentId != Guid.Empty ? item.ParentId : null); + var season = isSeason ? item : (seasonGuid.HasValue ? _libraryManager.GetItemById(seasonGuid.Value) : null); + + if (!seriesGuid.HasValue && season is not null && season.ParentId != Guid.Empty) + seriesGuid = season.ParentId; + + var series = seriesGuid.HasValue ? _libraryManager.GetItemById(seriesGuid.Value) : null; + if (isSeason) + { + seasonNumber = item.IndexNumber ?? 0; + } + + if (seasonNumber <= 0 || (!isSeason && episodeNumber <= 0) || series is null) + return false; + + var providerIds = series.ProviderIds ?? new Dictionary(); + if (!providerIds.TryGetValue("Tmdb", out var tmdbId) || string.IsNullOrWhiteSpace(tmdbId)) + return false; + + seriesTmdbId = tmdbId.Trim(); + return true; + } + + private static Guid? TryGetGuid(BaseItem item, string propertyName) + { + var property = item.GetType().GetProperty(propertyName); + var value = property?.GetValue(item); + return value switch + { + Guid guid when guid != Guid.Empty => guid, + string text when Guid.TryParse(text, out var guid) && guid != Guid.Empty => guid, + _ => (Guid?)null + }; + } + + private void Log(bool enabled, LogLevel level, string message, params object[] args) + { + if (enabled) + _logger.Log(level, message, args); + } + + private static void ValidateRefreshConfiguration(PluginConfiguration cfg) + { + var metadataProviders = cfg.Providers + .Where(p => p.MetadataOrder >= 0) + .OrderBy(p => p.MetadataOrder) + .Select(p => p.Id) + .ToArray(); + + if (!metadataProviders.Contains("tmdb", StringComparer.OrdinalIgnoreCase)) + throw new InvalidOperationException("TMDb must be enabled as a metadata provider before refresh can run."); + if (string.IsNullOrWhiteSpace(cfg.TmdbApiKey)) + throw new InvalidOperationException("TMDb API key is required before refresh can run."); + if (NormalizeLanguages(cfg.Languages).Length == 0) + throw new InvalidOperationException("At least one Multilang language must be configured before refresh can run."); + } + + private static string[] NormalizeLanguages(IEnumerable languages) + => languages.Select(l => (l ?? string.Empty).Trim()) + .Where(l => l.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty; +} diff --git a/src/Jellyfin.Plugin.Multilang/Services/Sorting/SortArticleCatalog.cs b/src/Jellyfin.Plugin.Multilang/Services/Sorting/SortArticleCatalog.cs new file mode 100644 index 0000000..b421532 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/Sorting/SortArticleCatalog.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Plugin.Multilang.Services; + +public sealed class SortArticleCatalog +{ + private static readonly Dictionary BuiltIns = new(StringComparer.OrdinalIgnoreCase) + { + ["en"] = ["a", "an", "the"], + ["en-US"] = ["a", "an", "the"], + ["en-GB"] = ["a", "an", "the"], + ["fi"] = [], + ["fi-FI"] = [], + ["sv"] = ["en", "ett"], + ["sv-SE"] = ["en", "ett"], + ["fr"] = ["l'", "le", "la", "les", "un", "une", "des"], + ["de"] = ["der", "die", "das", "ein", "eine"], + ["es"] = ["el", "la", "los", "las", "un", "una"], + ["it"] = ["il", "lo", "la", "gli", "le", "un", "una"] + }; + + public IReadOnlyDictionary GetBuiltIns() + => BuiltIns; +} diff --git a/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js b/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js new file mode 100644 index 0000000..53af922 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js @@ -0,0 +1,443 @@ +(() => { + const BASE_PATH = window.location.pathname.split("/web/")[0] || ""; + const MENU_ID = "mlUserRulesMenuItem"; + const USER_RULES_ROUTE = "multilang/user-rules"; + let lastMenuItemId = ""; + let userRulesRenderRun = 0; + + function getClientToken() { + try { + return window.ApiClient?.accessToken?.() || ""; + } catch (_) { + return ""; + } + } + + function hashParts() { + const raw = (location.hash || "").replace(/^#\/?/, ""); + const queryIndex = raw.indexOf("?"); + const path = (queryIndex >= 0 ? raw.slice(0, queryIndex) : raw).toLowerCase(); + const query = queryIndex >= 0 ? raw.slice(queryIndex + 1) : ""; + return { path, params: new URLSearchParams(query) }; + } + + function getCurrentUserId() { + const fromHash = hashParts().params.get("userId"); + if (fromHash) return fromHash; + try { + return window.ApiClient?.getCurrentUserId?.() || ""; + } catch (_) { + return ""; + } + } + + function userRulesHref() { + const params = new URLSearchParams(); + const userId = getCurrentUserId(); + if (userId) params.set("userId", userId); + params.set("multilang", "1"); + return `${BASE_PATH}/web/#/${USER_RULES_ROUTE}?${params.toString()}`; + } + + function isUserRulesRoute() { + const parts = hashParts(); + return parts.path === USER_RULES_ROUTE && parts.params.get("multilang") === "1"; + } + + function pathSegments(pathname) { + return String(pathname || "") + .toLowerCase() + .split("/") + .filter(Boolean); + } + + function looksLikeItemId(value) { + return /^[0-9a-f]{32}$/.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value); + } + + function isUserItemsEndpoint(segments) { + const usersIndex = segments.lastIndexOf("users"); + if (usersIndex < 0 || segments[usersIndex + 2] !== "items") return false; + + const tail = segments.slice(usersIndex + 3); + if (tail.length === 0) return true; + if (tail.length !== 1) return false; + return looksLikeItemId(tail[0]) || tail[0] === "latest" || tail[0] === "resume" || tail[0] === "nextup"; + } + + function isRootItemsEndpoint(segments) { + const itemsIndex = segments.lastIndexOf("items"); + if (itemsIndex < 0) return false; + + const tail = segments.slice(itemsIndex + 1); + if (tail.length === 0) return true; + if (tail.length !== 1) return false; + return looksLikeItemId(tail[0]) || tail[0] === "latest" || tail[0] === "resume" || tail[0] === "nextup"; + } + + function isShowsListEndpoint(segments) { + const showsIndex = segments.lastIndexOf("shows"); + if (showsIndex < 0) return false; + + const tail = segments.slice(showsIndex + 1); + if (tail.length === 1) return tail[0] === "nextup"; + if (tail.length === 2 && looksLikeItemId(tail[0])) return tail[1] === "episodes" || tail[1] === "seasons"; + return false; + } + + function isGenresEndpoint(segments) { + const genresIndex = segments.lastIndexOf("genres"); + if (genresIndex < 0) return false; + + const tail = segments.slice(genresIndex + 1); + return tail.length === 0 || (tail.length === 1 && looksLikeItemId(tail[0])); + } + + function shouldProxyEndpoint(pathname) { + const segments = pathSegments(pathname); + return isUserItemsEndpoint(segments) || + isRootItemsEndpoint(segments) || + isShowsListEndpoint(segments) || + isGenresEndpoint(segments); + } + + function getItemsProxyUrl(urlInput) { + const url = urlInput instanceof Request ? urlInput.url : String(urlInput || ""); + if (!url) return null; + const u = new URL(url, window.location.origin); + if (u.origin !== window.location.origin) return null; + const lower = u.pathname.toLowerCase(); + if (lower.includes("/multilang/")) return null; + if (lower.includes("/images/") || lower.endsWith("/images")) return null; + if (!shouldProxyEndpoint(u.pathname)) return null; + + const locale = document.documentElement?.lang?.trim() || navigator.language || ""; + const token = getClientToken(); + const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ""; + const localeParam = locale ? `&mlLocale=${encodeURIComponent(locale)}` : ""; + return `${BASE_PATH}/Multilang/ItemsProxy?url=${encodeURIComponent(`${u.pathname}${u.search}`)}${localeParam}${tokenParam}`; + } + + function ensureUserRulesLink() { + let menuItem = document.getElementById(MENU_ID); + if (!(location.hash || "").toLowerCase().includes("mypreferences")) { + menuItem?.remove(); + return; + } + + const href = userRulesHref(); + if (menuItem) { + menuItem.href = href; + wireUserRulesLink(menuItem); + return; + } + + const links = [...document.querySelectorAll('a[href^="#/mypreferences"]')]; + const insertAfter = links[links.length - 1]; + if (!insertAfter) return; + + const cloned = insertAfter.cloneNode(true); + cloned.id = MENU_ID; + cloned.dataset.mlUserRulesLink = "1"; + cloned.href = href; + cloned.removeAttribute("title"); + cloned.removeAttribute("aria-label"); + [...cloned.classList] + .filter((c) => c.startsWith("lnk")) + .forEach((c) => cloned.classList.remove(c)); + const textEl = cloned.querySelector(".listItemBodyText"); + if (textEl) textEl.textContent = "Metadata language rules"; + cloned.querySelectorAll(".material-icons").forEach((el) => el.remove()); + const iconEl = document.createElement("span"); + iconEl.className = "material-icons listItemIcon listItemIcon-transparent translate"; + iconEl.setAttribute("aria-hidden", "true"); + (cloned.querySelector(".listItemBody") || cloned).before(iconEl); + wireUserRulesLink(cloned); + insertAfter.parentElement.insertBefore(cloned, insertAfter.nextSibling); + } + + function wireUserRulesLink(link) { + if (link.dataset.mlUserRulesClick === "1") return; + link.dataset.mlUserRulesClick = "1"; + link.addEventListener("click", (event) => { + openUserRulesPage(event); + }, true); + } + + function visiblePage() { + const pages = [...document.querySelectorAll(".mainAnimatedPage, .page")]; + return pages.reverse().find((page) => { + if (page.closest("#ml-user") || page.classList.contains("hide")) return false; + const style = window.getComputedStyle(page); + return style.display !== "none" && style.visibility !== "hidden"; + }); + } + + function userRulesHost() { + return ensureUserRulesShell().querySelector("#ml-user-host"); + } + + function ensureUserRulesShell() { + let shell = document.getElementById("ml-user-shell"); + if (shell) { + positionUserRulesShell(shell); + shell.hidden = false; + return shell; + } + + shell = document.createElement("div"); + shell.id = "ml-user-shell"; + shell.className = "mainAnimatedPage type-interior page"; + shell.style.position = "fixed"; + shell.style.left = "0"; + shell.style.right = "0"; + shell.style.bottom = "0"; + shell.style.zIndex = "100"; + shell.style.overflow = "auto"; + shell.style.background = "var(--theme-body-bg, var(--background-color, #101010))"; + shell.style.color = "inherit"; + + const host = document.createElement("div"); + host.id = "ml-user-host"; + host.className = "content-primary"; + shell.appendChild(host); + document.body.appendChild(shell); + positionUserRulesShell(shell); + return shell; + } + + function positionUserRulesShell(shell) { + const header = document.querySelector(".skinHeader, .headerTop, header"); + const top = header ? Math.max(0, Math.round(header.getBoundingClientRect().bottom)) : 0; + shell.style.top = `${top}px`; + } + + function closeUserRulesShellIfInactive() { + if (isUserRulesRoute()) return; + document.getElementById("ml-user-shell")?.remove(); + } + + function openUserRulesPage(event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + event?.stopImmediatePropagation?.(); + history.pushState({}, "", userRulesHref()); + scheduleUserRulesPageRender(); + } + + function handleUserRulesClick(event) { + const link = event.target?.closest?.(`#${MENU_ID}, [data-ml-user-rules-link='1'], a[href*="multilang=1"]`); + if (!link) return; + openUserRulesPage(event); + } + + function executeScript(script) { + return new Promise((resolve, reject) => { + const next = document.createElement("script"); + [...script.attributes].forEach((attr) => next.setAttribute(attr.name, attr.value)); + if (script.src) { + next.onload = () => resolve(); + next.onerror = () => reject(new Error(`Failed to load ${script.src}`)); + next.src = script.src; + } else { + next.text = script.textContent || ""; + } + document.body.appendChild(next); + if (!script.src) resolve(); + }); + } + + async function renderUserRulesPage(run) { + if (!isUserRulesRoute() || run !== userRulesRenderRun) return; + + const host = userRulesHost(); + if (!host) return; + if (host.dataset.mlUserRulesRendered === "1" && document.getElementById("ml-user")) return; + + host.dataset.mlUserRulesRendered = "1"; + host.innerHTML = "
      Loading Multilang settings...
      "; + + const response = await fetch(`${BASE_PATH}/web/configurationpage?name=MultilangUser`, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const fragment = await response.text(); + if (run !== userRulesRenderRun || !isUserRulesRoute()) return; + + const parsed = new DOMParser().parseFromString(fragment, "text/html"); + const sourcePage = parsed.getElementById("ml-user"); + const sourceContent = sourcePage?.querySelector?.(".content-primary"); + if (!sourceContent) throw new Error("Multilang user page fragment missing"); + + const scripts = [...sourceContent.querySelectorAll("script")]; + scripts.forEach((script) => script.remove()); + + const page = document.createElement("div"); + page.id = "ml-user"; + page.className = "ml-root"; + while (sourceContent.firstChild) page.appendChild(sourceContent.firstChild); + host.replaceChildren(page); + + for (const script of scripts) { + if (run !== userRulesRenderRun || !isUserRulesRoute()) return; + await executeScript(script); + } + } + + function scheduleUserRulesPageRender() { + userRulesRenderRun++; + if (!isUserRulesRoute()) { + closeUserRulesShellIfInactive(); + return; + } + + const run = userRulesRenderRun; + [0, 100, 350].forEach((delay) => { + setTimeout(() => { + renderUserRulesPage(run).catch((err) => console.error("[Multilang] failed to render user rules page", err)); + }, delay); + }); + } + + function captureLastItemId(target) { + const el = target?.closest?.("[data-id]"); + const id = (el?.getAttribute("data-id") || "").match(/[0-9a-f]{32}/i)?.[0]; + if (id) lastMenuItemId = id; + } + + function collectDialogItemIds(form) { + const scan = (value) => String(value || "").match(/[0-9a-f]{32}/ig) || []; + return [...new Set([...scan(form?.querySelector?.(".fldSelectedItemIds")?.value), ...scan(lastMenuItemId), ...scan(location.hash)])]; + } + + function refreshItems(itemIds) { + const token = getClientToken(); + const headers = token ? { "X-Emby-Token": token, "X-MediaBrowser-Token": token } : {}; + itemIds.forEach((itemId) => { + fetch(`${BASE_PATH}/Multilang/RefreshItem/${itemId}?includeChildren=true${token ? `&token=${encodeURIComponent(token)}` : ""}`, { method: "POST", headers }); + }); + } + + function openDebugItem(itemIds) { + const itemId = itemIds[0]; + if (!itemId) return; + const token = getClientToken(); + const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ""; + window.open(`${BASE_PATH}/Multilang/DebugUi/${encodeURIComponent(itemId)}${tokenParam}`, "_blank", "noopener"); + } + + function ensureRefreshDialogOption(root) { + const container = root?.closest?.(".formDialog") || root?.querySelector?.(".formDialog"); + const title = container?.querySelector?.(".formDialogHeaderTitle"); + const select = container?.querySelector?.("#selectMetadataRefreshMode"); + if (!container || !title || !/refresh metadata/i.test(title.textContent || "") || !select || select.querySelector("option[value='multilang']")) return; + + const option = document.createElement("option"); + option.value = "multilang"; + option.textContent = "Refresh Multilang metadata"; + select.appendChild(option); + + if (!container.querySelector("[data-ml-debug-item]")) { + const debugButton = document.createElement("button"); + debugButton.type = "button"; + debugButton.className = "raised button-submit emby-button"; + debugButton.dataset.mlDebugItem = "1"; + debugButton.textContent = "Debug Multilang"; + debugButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openDebugItem(collectDialogItemIds(container)); + }, true); + (select.closest(".inputContainer") || select.parentElement || container).appendChild(debugButton); + } + + const form = container.querySelector("form"); + if (!form || form.dataset.mlHooked === "1") return; + form.dataset.mlHooked = "1"; + form.addEventListener("submit", (event) => { + if (select.value !== "multilang") return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + refreshItems(collectDialogItemIds(form)); + (container.closest(".dialogContainer") || container).remove(); + document.querySelectorAll(".dialogBackdrop,.dialog-overlay,.dialog-backdrop").forEach((el) => el.remove()); + }, true); + } + + function mlUrlFromImageUrl(rawUrl) { + const urlText = String(rawUrl || "").replace(/&/gi, "&"); + if (!/tag=ml(?::|%3a|%253a)/i.test(urlText)) return ""; + const u = new URL(urlText, window.location.origin); + const tag = u.searchParams.get("tag") || ""; + if (!tag.startsWith("ml:")) return ""; + let url = decodeURIComponent(tag.slice(3)).trim(); + if (!url) return ""; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + if (!url.startsWith("/")) url = "/" + url; + if (BASE_PATH && !url.toLowerCase().startsWith(BASE_PATH.toLowerCase() + "/")) url = BASE_PATH + url; + } + return url; + } + + function rewriteElementImages(el) { + if (!el || el.nodeType !== 1) return; + ["src", "data-src"].forEach((attr) => { + const current = el.getAttribute(attr); + const next = mlUrlFromImageUrl(current); + if (next) el.setAttribute(attr, next); + }); + const style = el.getAttribute("style"); + if (style) { + const next = style.replace(/url\((['"]?)([^'")]+)\1\)/g, (match, quote, url) => { + const ml = mlUrlFromImageUrl(url); + return ml ? `url("${ml}")` : match; + }); + if (next !== style) el.setAttribute("style", next); + } + } + + function scanNode(node) { + if (!node || node.nodeType !== 1) return; + rewriteElementImages(node); + node.querySelectorAll("[style],[src],[data-src]").forEach(rewriteElementImages); + ensureRefreshDialogOption(node); + } + + const originalFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + const method = (init && init.method) || (input instanceof Request ? input.method : "GET"); + if (String(method).toUpperCase() !== "GET") return originalFetch(input, init); + const proxyUrl = getItemsProxyUrl(input); + if (!proxyUrl) return originalFetch(input, init); + if (input instanceof Request) return originalFetch(new Request(proxyUrl, input)); + return originalFetch(proxyUrl, init); + }; + + const originalXhrOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url) { + const proxyUrl = typeof method === "string" && method.toUpperCase() === "GET" ? getItemsProxyUrl(url) : null; + return proxyUrl ? originalXhrOpen.call(this, method, proxyUrl, arguments[2], arguments[3], arguments[4]) : originalXhrOpen.apply(this, arguments); + }; + + const observer = new MutationObserver((mutations) => mutations.forEach((m) => { + if (m.type === "childList") m.addedNodes.forEach(scanNode); + if (m.type === "attributes") rewriteElementImages(m.target); + })); + observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "src", "data-src"] }); + + document.addEventListener("pointerdown", (event) => captureLastItemId(event.target)); + document.addEventListener("click", handleUserRulesClick, true); + document.addEventListener("viewshow", () => requestAnimationFrame(() => { + ensureUserRulesLink(); + scheduleUserRulesPageRender(); + })); + window.addEventListener("hashchange", () => requestAnimationFrame(() => { + ensureUserRulesLink(); + scheduleUserRulesPageRender(); + })); + requestAnimationFrame(() => { + scanNode(document.documentElement); + ensureUserRulesLink(); + scheduleUserRulesPageRender(); + }); +})();