using System.Reflection; using System.Diagnostics; using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using static Jellyfin.Plugin.Multilang.MultilangConstants; 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.Assets; using Jellyfin.Plugin.Multilang.Services.Backup; 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 MediaBrowser.Model.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; 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 ILocalizationManager _localizationManager; private readonly ProviderCatalog _providerCatalog; private readonly SortArticleCatalog _articleCatalog; private readonly RefreshService _refreshService; private readonly FanartClient _fanartClient; private readonly ItemsProxyCache _itemsProxyCache; private readonly ItemsProxyTransformer _itemsProxyTransformer; private readonly AssetStorageService _assetStorage; private readonly MultilangBackupService _backupService; public MultilangController( TranslationStore store, ISessionManager sessionManager, IAuthorizationContext authorizationContext, ILibraryManager libraryManager, IUserManager userManager, IHttpClientFactory httpClientFactory, IApplicationPaths appPaths, ILocalizationManager localizationManager, ProviderCatalog providerCatalog, SortArticleCatalog articleCatalog, RefreshService refreshService, FanartClient fanartClient, ItemsProxyCache itemsProxyCache, ItemsProxyTransformer itemsProxyTransformer, AssetStorageService assetStorage, MultilangBackupService backupService) { _store = store; _sessionManager = sessionManager; _authorizationContext = authorizationContext; _libraryManager = libraryManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _appPaths = appPaths; _localizationManager = localizationManager; _providerCatalog = providerCatalog; _articleCatalog = articleCatalog; _refreshService = refreshService; _fanartClient = fanartClient; _itemsProxyCache = itemsProxyCache; _itemsProxyTransformer = itemsProxyTransformer; _assetStorage = assetStorage; _backupService = backupService; } [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("Assets/{**path}")] public IActionResult Asset(string path) => _assetStorage.TryResolveLocalAsset(path, out var fullPath, out var contentType) ? PhysicalFile(fullPath, contentType) : NotFound(); [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("MetadataLanguages")] public IActionResult MetadataLanguages() => Ok(_localizationManager.GetCultures() .Where(c => !string.IsNullOrWhiteSpace(c.Name)) .OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) .Select(c => new { c.Name, c.DisplayName })); [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) || !string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.OrdinalIgnoreCase) || previous.IgnoreArticlesWhenSorting != next.IgnoreArticlesWhenSorting || !string.Equals(previous.JellyfinTitleLanguageFallback, next.JellyfinTitleLanguageFallback, StringComparison.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); _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 = ItemsProxyRequestBuilder.Build(Request, 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 && _itemsProxyTransformer.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: ItemsProxyTransformer.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(); var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls); body = transformed.Body; responseItemIds = transformed.ItemIds; 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 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, ItemsProxyResolvedText Result, string SelectedAction, string Reason); private sealed record DebugAssetResolution( string Kind, string[] Actions, DebugResolutionAttempt[] Attempts, ItemsProxyResolvedAsset Result, string SelectedAction, string Reason); [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("ExportInfo")] public async Task ExportInfo() { if (!await IsRequesterAdminAsync().ConfigureAwait(false)) return Unauthorized(new { Error = "AdminRequired" }); var storage = _backupService.GetStorageInfo(); return Ok(new { storage.DatabaseBytes, storage.AssetsBytes, CleanupDataOnUninstall = Plugin.Instance?.Configuration?.CleanupDataOnUninstall == true, AssetStorageMode = Plugin.Instance?.Configuration?.AssetStorageMode ?? "url" }); } [HttpGet("Export")] public async Task Export( [FromQuery] bool pluginSettings = true, [FromQuery] bool userSettings = true, [FromQuery] bool translationsDatabase = false, [FromQuery] bool downloadedAssets = false) { if (!await IsRequesterAdminAsync().ConfigureAwait(false)) return Unauthorized(new { Error = "AdminRequired" }); if (downloadedAssets && !translationsDatabase) return BadRequest(new { Error = "AssetsRequireDatabase" }); var bytes = _backupService.Export( new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets), Plugin.Instance?.Configuration ?? new PluginConfiguration()); var name = "multilang-export-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".zip"; return File(bytes, "application/zip", name); } [HttpPost("Import")] public async Task Import() { if (!await IsRequesterAdminAsync().ConfigureAwait(false)) return Unauthorized(new { Error = "AdminRequired" }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false); var file = form.Files.GetFile("file"); if (file is null || file.Length == 0) return BadRequest(new { Error = "MissingFile" }); var options = new BackupImportOptions( FormBool(form, "pluginSettings", true), FormBool(form, "userSettings", true), FormBool(form, "translationsDatabase", true), FormBool(form, "downloadedAssets", true)); if (options.DownloadedAssets && !options.TranslationsDatabase) return BadRequest(new { Error = "AssetsRequireDatabase" }); try { await using var stream = file.OpenReadStream(); var result = _backupService.ImportAdmin( stream, options, Plugin.Instance?.Configuration ?? new PluginConfiguration(), cfg => Plugin.Instance?.UpdateConfiguration(cfg)); _itemsProxyCache.ClearAll(); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(new { Error = ex.Message }); } } [HttpPost("CleanupAll")] public async Task CleanupAll([FromBody] CleanupAllRequest request) { if (!await IsRequesterAdminAsync().ConfigureAwait(false)) return Unauthorized(new { Error = "AdminRequired" }); if (request.Confirm != true) return BadRequest(new { Error = "ConfirmationRequired" }); Plugin.Instance?.UpdateConfiguration(new PluginConfiguration()); _store.ResetAll(); _itemsProxyCache.ClearAll(); return Ok(new { Cleared = true }); } [HttpGet("UserRules/self/export")] public async Task ExportSelfRules() { var userId = await GetUserIdAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(new { Error = "NotAuthenticated" }); var bytes = _backupService.ExportUserRules(userId, GetRulesOrDefault(userId)); var name = "multilang-user-rules-" + userId + ".zip"; return File(bytes, "application/zip", name); } [HttpPost("UserRules/self/import/inspect")] public async Task InspectSelfRulesImport() { var userId = await GetUserIdAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(new { Error = "NotAuthenticated" }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false); var file = form.Files.GetFile("file"); if (file is null || file.Length == 0) return BadRequest(new { Error = "MissingFile" }); try { await using var stream = file.OpenReadStream(); return Ok(_backupService.InspectUserImport(stream, userId)); } catch (InvalidOperationException ex) { return BadRequest(new { Error = ex.Message }); } } [HttpPost("UserRules/self/import")] public async Task ImportSelfRules() { var userId = await GetUserIdAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(new { Error = "NotAuthenticated" }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false); var file = form.Files.GetFile("file"); if (file is null || file.Length == 0) return BadRequest(new { Error = "MissingFile" }); try { await using var stream = file.OpenReadStream(); var result = _backupService.ImportUser(stream, userId, form.TryGetValue("sourceUserId", out var sourceUserId) ? sourceUserId.ToString() : null); if (result.UserSettingsImported > 0) _itemsProxyCache.ClearAll(); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(new { Error = ex.Message }); } } [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 = _itemsProxyTransformer.GetNeededLanguages(rules); var debugLanguages = configuredLanguages .Concat(ruleLanguages) .Append(OriginalAction) .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 = _itemsProxyTransformer.GetClassificationFacts(rules, facts); var categoryTraces = (rules.Categories ?? []) .Select(category => CategoryRuleEvaluator.Trace(category, classificationFacts)) .ToArray(); var categoryMatch = _itemsProxyTransformer.PickCategoryMatch(rules, classificationFacts); var category = categoryMatch.Effective; var clientLocale = Request.Query.TryGetValue("mlLocale", out var mlLocale) ? mlLocale.ToString() : string.Empty; var effectiveSortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, clientLocale); var genreLocale = string.IsNullOrWhiteSpace(clientLocale) ? effectiveSortLocale : clientLocale; var actionLists = ActionFields.ToDictionary( field => field, field => ItemsProxyTransformer.GetActionList(rules, category, field).ToArray(), StringComparer.OrdinalIgnoreCase); var title = TraceResolveField(TitleField, actionLists[TitleField], facts, byLang); var overview = TraceResolveField(OverviewField, actionLists[OverviewField], facts, byLang); var tagline = TraceResolveField(TaglineField, actionLists[TaglineField], facts, byLang); var poster = TraceResolveAsset(PosterKind, actionLists[PosterKind], byKind); var logo = TraceResolveAsset(LogoKind, actionLists[LogoKind], byKind); var banner = TraceResolveAsset(BannerKind, actionLists[BannerKind], byKind); var thumb = TraceResolveAsset(ThumbKind, actionLists[ThumbKind], byKind); var backdrop = TraceResolveAsset(BackdropKind, actionLists[BackdropKind], 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 = FallbackAction } as object, EffectiveCategory = category ?? new { Id = "fallback", Label = FallbackAction } 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(JellyfinAction, StringComparison.OrdinalIgnoreCase)) { var result = new ItemsProxyResolvedText(false, null); attempts.Add(new DebugResolutionAttempt(action, JellyfinAction, string.Empty, true, true, null, "Use Jellyfin value")); return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached"); } if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) && action.Equals(OriginalAction, 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 ItemsProxyResolvedText(true, facts.OriginalTitle); return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original title found"); } continue; } if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) { var originalValue = ItemsProxyTransformer.GetTranslatedField(byLang, OriginalAction, 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 ItemsProxyResolvedText(true, originalValue); return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original-language translation found"); } continue; } if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) { attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token")); continue; } var lang = action[LanguagePrefix.Length..]; var value = ItemsProxyTransformer.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 ItemsProxyResolvedText(true, value); return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Translation found"); } } var fallback = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) ? new ItemsProxyResolvedText(false, null) : new ItemsProxyResolvedText(true, string.Empty); var reason = field.Equals(TitleField, 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(TitleField, StringComparison.OrdinalIgnoreCase) ? JellyfinAction : ClearAction, reason); } private static DebugAssetResolution TraceResolveAsset( string kind, IReadOnlyList actions, Dictionary>? byKind) { var attempts = new List(); foreach (var action in actions) { if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)) { var result = new ItemsProxyResolvedAsset(false, null); attempts.Add(new DebugResolutionAttempt(action, JellyfinAction, string.Empty, true, true, null, "Use Jellyfin image")); return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached"); } if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) { var originalValue = ItemsProxyTransformer.GetAsset(byKind, kind, OriginalAction); 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 ItemsProxyResolvedAsset(true, originalValue); return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Original-language asset found"); } continue; } if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) { attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token")); continue; } var lang = action[LanguagePrefix.Length..]; var value = ItemsProxyTransformer.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 ItemsProxyResolvedAsset(true, value); return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Asset found"); } } var fallback = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase) ? new ItemsProxyResolvedAsset(false, null) : new ItemsProxyResolvedAsset(true, string.Empty); var reason = kind.Equals(PosterKind, 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(PosterKind, StringComparison.OrdinalIgnoreCase) ? JellyfinAction : ClearAction, reason); } private static object[] BuildMissingDataReport( IEnumerable configuredLanguages, Dictionary>? byLang, Dictionary>? byKind) { return configuredLanguages .Where(lang => !string.IsNullOrWhiteSpace(lang)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(lang => lang, StringComparer.OrdinalIgnoreCase) .Select(lang => { var missingText = TextFields .Where(field => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetTranslatedField(byLang, lang, field))) .ToArray(); var missingAssets = AssetKinds .Where(kind => string.IsNullOrWhiteSpace(ItemsProxyTransformer.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(ItemsProxyResolvedText text) => new { Source = text.Change ? "Multilang" : JellyfinAction, Value = text.Value, ClearsValue = text.Change && string.IsNullOrWhiteSpace(text.Value) }; private static object DebugAssetOutput(ItemsProxyResolvedAsset asset) => new { Source = asset.Change ? "Multilang" : JellyfinAction, Value = asset.Change && !string.IsNullOrWhiteSpace(asset.Value) ? ItemsProxyTransformer.ToImageTag(asset.Value) : asset.Value, ClearsValue = asset.Change && string.IsNullOrWhiteSpace(asset.Value) }; private object[] ResolveGenresForDebug(FactsData facts, string locale) { var media = ItemsProxyTransformer.GenreMediaForFacts(facts); if (media.Length == 0) return []; var namesById = _store.GetGenreNames(media, locale); return ItemsProxyTransformer.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(); } [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.FallbackFieldActions = NormalizeActionLists(existing.FallbackFieldActions, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase)); existing.Categories = NormalizeCategories(existing.Categories, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase)); return existing; } return new UserRulesDocument { Enabled = false, SortLocale = "Auto", TrustTmdbCollections = true, FallbackFieldActions = DefaultActionLists(), Categories = [] }; } 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(JellyfinAction, StringComparison.OrdinalIgnoreCase)); private static bool HasMeaningfulLegacyActions(IReadOnlyDictionary legacyActions) => legacyActions.Values.Any(action => !string.IsNullOrWhiteSpace(action) && !action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase) && !action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)); private static string[] LegacyActionToList(string field, IReadOnlyDictionary? legacyActions) { if (legacyActions is null || !legacyActions.TryGetValue(field, out var action)) return [JellyfinAction]; if (string.IsNullOrWhiteSpace(action) || action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase)) return [JellyfinAction]; 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(JellyfinAction, StringComparison.OrdinalIgnoreCase) || action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase)) return action; if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) return OriginalAction; if ((field is OverviewField or TaglineField or LogoKind) && action.Equals(ClearAction, StringComparison.OrdinalIgnoreCase)) return ClearAction; if (action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) { var lang = action[LanguagePrefix.Length..].Trim(); var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(canonical)) return LanguagePrefix + canonical; } return FallbackAction; } 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 (RequiresJellyfinFallback(field)) { if (!result.Contains(JellyfinAction, StringComparer.OrdinalIgnoreCase)) result.Add(JellyfinAction); } 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 JellyfinAction; if (action.Equals("O", StringComparison.OrdinalIgnoreCase)) return OriginalAction; if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)) return JellyfinAction; if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) return OriginalAction; var lang = action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase) ? action[LanguagePrefix.Length..].Trim() : action; var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase)); return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical; } 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 FormBool(IFormCollection form, string key, bool fallback) { if (!form.TryGetValue(key, out var values)) return fallback; var value = values.ToString(); return value.Equals("1", StringComparison.OrdinalIgnoreCase) || value.Equals("true", StringComparison.OrdinalIgnoreCase) || value.Equals("on", StringComparison.OrdinalIgnoreCase); } 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 record CleanupAllRequest(bool Confirm); 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 string AssetStorageMode { get; set; } = "url"; public bool IgnoreArticlesWhenSorting { get; set; } = true; public string JellyfinTitleLanguageFallback { get; set; } = string.Empty; public SortArticleEntry[] ArticleEntries { get; set; } = []; public bool EnableLogging { get; set; } public bool VerboseLogging { get; set; } public bool CleanupDataOnUninstall { 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, AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode), IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting, JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback, ArticleEntries = cfg.ArticleEntries, EnableLogging = cfg.EnableLogging, VerboseLogging = cfg.VerboseLogging, CleanupDataOnUninstall = cfg.CleanupDataOnUninstall }; 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), AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode), IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting, JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty, 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, CleanupDataOnUninstall = CleanupDataOnUninstall }; 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 NormalizeAssetStorageMode(string? value) => string.Equals(value?.Trim(), "local", StringComparison.OrdinalIgnoreCase) ? "local" : "url"; }