From 933123608dc51461c8a703ec426c3e737e671b50 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Wed, 15 Jul 2026 22:29:00 +0000 Subject: [PATCH] Extract ItemsProxy transformer --- .../Api/MultilangController.cs | 603 ++---------------- .../PluginServiceRegistrator.cs | 1 + .../Services/ItemsProxyTransformer.cs | 575 +++++++++++++++++ 3 files changed, 612 insertions(+), 567 deletions(-) create mode 100644 src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs diff --git a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs index 49dea94..c9dfebf 100644 --- a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs +++ b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs @@ -47,6 +47,7 @@ public sealed class MultilangController : ControllerBase private readonly RefreshService _refreshService; private readonly FanartClient _fanartClient; private readonly ItemsProxyCache _itemsProxyCache; + private readonly ItemsProxyTransformer _itemsProxyTransformer; private readonly AssetStorageService _assetStorage; private readonly MultilangBackupService _backupService; @@ -63,6 +64,7 @@ public sealed class MultilangController : ControllerBase RefreshService refreshService, FanartClient fanartClient, ItemsProxyCache itemsProxyCache, + ItemsProxyTransformer itemsProxyTransformer, AssetStorageService assetStorage, MultilangBackupService backupService) { @@ -78,6 +80,7 @@ public sealed class MultilangController : ControllerBase _refreshService = refreshService; _fanartClient = fanartClient; _itemsProxyCache = itemsProxyCache; + _itemsProxyTransformer = itemsProxyTransformer; _assetStorage = assetStorage; _backupService = backupService; } @@ -286,7 +289,7 @@ public sealed class MultilangController : ControllerBase return Content(cached.Body, cached.ContentType); } - if (rules.Enabled && proxyRequest.IsGenresRequest && TryBuildGenresResponse(proxyRequest.GenreMedia, proxyRequest.Controls.ClientLocale, out var genresBody)) + 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); @@ -295,7 +298,7 @@ public sealed class MultilangController : ControllerBase cacheHit: false, cacheStored: false, statusCode: 200, - itemCount: CountRootArrayItems(genresBody), + itemCount: ItemsProxyTransformer.CountRootArrayItems(genresBody), totalMs: totalSw.ElapsedMilliseconds, upstreamMs: 0, transformMs: totalSw.ElapsedMilliseconds, @@ -316,7 +319,9 @@ public sealed class MultilangController : ControllerBase if (response.IsSuccessStatusCode && rules.Enabled) { var transformSw = Stopwatch.StartNew(); - body = TransformItemsResponse(body, rules, proxyRequest.Controls, out responseItemIds); + var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls); + body = transformed.Body; + responseItemIds = transformed.ItemIds; transformSw.Stop(); transformMs = transformSw.ElapsedMilliseconds; } @@ -377,223 +382,6 @@ public sealed class MultilangController : ControllerBase }; } - 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 = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale); - var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale); - var sortArticles = ItemsProxySorting.GetSortArticles(_articleCatalog, Plugin.Instance?.Configuration?.ArticleEntries ?? [], sortLocale); - - var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.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(TitleField, GetActionList(rules, category, TitleField), facts, byLang); - if (title.Change) - item["Name"] = title.Value ?? string.Empty; - - var overview = ResolveField(OverviewField, GetActionList(rules, category, OverviewField), facts, byLang); - if (overview.Change) - { - if (string.IsNullOrWhiteSpace(overview.Value)) - item.Remove("Overview"); - else - item["Overview"] = overview.Value; - } - - var tagline = ResolveField(TaglineField, GetActionList(rules, category, TaglineField), 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(PosterKind, GetActionList(rules, category, PosterKind), assetsByKind)); - ApplyImageAsset(item, "Logo", ResolveAsset(LogoKind, GetActionList(rules, category, LogoKind), assetsByKind)); - ApplyImageAsset(item, "Banner", ResolveAsset(BannerKind, GetActionList(rules, category, BannerKind), assetsByKind)); - ApplyImageAsset(item, "Thumb", ResolveAsset(ThumbKind, GetActionList(rules, category, ThumbKind), assetsByKind)); - ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), 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(OriginalAction, StringComparison.OrdinalIgnoreCase)) - { - langs.Add(OriginalAction); - return; - } - - if (action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) - langs.Add(action[LanguagePrefix.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, @@ -607,7 +395,7 @@ public sealed class MultilangController : ControllerBase string Field, string[] Actions, DebugResolutionAttempt[] Attempts, - ResolvedText Result, + ItemsProxyResolvedText Result, string SelectedAction, string Reason); @@ -615,303 +403,10 @@ public sealed class MultilangController : ControllerBase string Kind, string[] Actions, DebugResolutionAttempt[] Attempts, - ResolvedAsset Result, + ItemsProxyResolvedAsset 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(JellyfinAction, StringComparison.OrdinalIgnoreCase)) - return new ResolvedText(false, null); - if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) && - action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(facts.OriginalTitle)) - return new ResolvedText(true, facts.OriginalTitle); - if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) - { - var originalValue = GetTranslatedField(byLang, OriginalAction, field); - if (!string.IsNullOrWhiteSpace(originalValue)) - return new ResolvedText(true, originalValue); - continue; - } - - if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) - continue; - - var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field); - if (!string.IsNullOrWhiteSpace(value)) - return new ResolvedText(true, value); - } - - return field.Equals(TitleField, 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(JellyfinAction, StringComparison.OrdinalIgnoreCase)) - return new ResolvedAsset(false, null); - if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) - { - var originalValue = GetAsset(byKind, kind, OriginalAction); - if (!string.IsNullOrWhiteSpace(originalValue)) - return new ResolvedAsset(true, originalValue); - continue; - } - - if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) - continue; - - var value = GetAsset(byKind, kind, action[LanguagePrefix.Length..]); - if (!string.IsNullOrWhiteSpace(value)) - return new ResolvedAsset(true, value); - } - - return kind.Equals(PosterKind, 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 names = _store.GetGenreNames(media, locale); - if (names.Count == 0) - { - body = string.Empty; - return false; - } - - var culture = ItemsProxySorting.GetSortCulture(ItemsProxySorting.ResolveSortLocale(locale, locale)); - var comparer = StringComparer.Create(culture, true); - var items = names - .Where(kv => !string.IsNullOrWhiteSpace(kv.Value)) - .OrderBy(kv => kv.Value, comparer) - .Select(g => new JsonObject - { - ["Name"] = g.Value, - ["Id"] = "tmdb-" + g.Key.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 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[] 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() { @@ -1160,7 +655,7 @@ public sealed class MultilangController : ControllerBase var rules = GetRulesOrDefault(userId); var configuredLanguages = Plugin.Instance?.Configuration?.Languages ?? []; - var ruleLanguages = GetNeededLanguages(rules); + var ruleLanguages = _itemsProxyTransformer.GetNeededLanguages(rules); var debugLanguages = configuredLanguages .Concat(ruleLanguages) .Append(OriginalAction) @@ -1171,11 +666,11 @@ public sealed class MultilangController : ControllerBase var assets = _store.GetAssets([itemId32], debugLanguages); translations.TryGetValue(itemId32, out var byLang); assets.TryGetValue(itemId32, out var byKind); - var classificationFacts = GetClassificationFacts(rules, facts); + var classificationFacts = _itemsProxyTransformer.GetClassificationFacts(rules, facts); var categoryTraces = (rules.Categories ?? []) .Select(category => CategoryRuleEvaluator.Trace(category, classificationFacts)) .ToArray(); - var categoryMatch = PickCategoryMatch(rules, classificationFacts); + 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); @@ -1183,7 +678,7 @@ public sealed class MultilangController : ControllerBase var actionLists = ActionFields.ToDictionary( field => field, - field => GetActionList(rules, category, field).ToArray(), + field => ItemsProxyTransformer.GetActionList(rules, category, field).ToArray(), StringComparer.OrdinalIgnoreCase); var title = TraceResolveField(TitleField, actionLists[TitleField], facts, byLang); @@ -1282,7 +777,7 @@ public sealed class MultilangController : ControllerBase { if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)) { - var result = new ResolvedText(false, null); + 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"); } @@ -1294,7 +789,7 @@ public sealed class MultilangController : ControllerBase 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); + var result = new ItemsProxyResolvedText(true, facts.OriginalTitle); return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original title found"); } @@ -1303,12 +798,12 @@ public sealed class MultilangController : ControllerBase if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) { - var originalValue = GetTranslatedField(byLang, OriginalAction, field); + 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 ResolvedText(true, originalValue); + var result = new ItemsProxyResolvedText(true, originalValue); return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original-language translation found"); } @@ -1322,19 +817,19 @@ public sealed class MultilangController : ControllerBase } var lang = action[LanguagePrefix.Length..]; - var value = GetTranslatedField(byLang, lang, field); + 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 ResolvedText(true, value); + 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 ResolvedText(false, null) - : new ResolvedText(true, string.Empty); + ? 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"; @@ -1351,19 +846,19 @@ public sealed class MultilangController : ControllerBase { if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)) { - var result = new ResolvedAsset(false, null); + 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 = GetAsset(byKind, kind, OriginalAction); + 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 ResolvedAsset(true, originalValue); + var result = new ItemsProxyResolvedAsset(true, originalValue); return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Original-language asset found"); } @@ -1377,19 +872,19 @@ public sealed class MultilangController : ControllerBase } var lang = action[LanguagePrefix.Length..]; - var value = GetAsset(byKind, kind, lang); + 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 ResolvedAsset(true, value); + 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 ResolvedAsset(false, null) - : new ResolvedAsset(true, string.Empty); + ? 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"; @@ -1408,10 +903,10 @@ public sealed class MultilangController : ControllerBase .Select(lang => { var missingText = TextFields - .Where(field => string.IsNullOrWhiteSpace(GetTranslatedField(byLang, lang, field))) + .Where(field => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetTranslatedField(byLang, lang, field))) .ToArray(); var missingAssets = AssetKinds - .Where(kind => string.IsNullOrWhiteSpace(GetAsset(byKind, kind, lang))) + .Where(kind => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetAsset(byKind, kind, lang))) .ToArray(); return new { @@ -1424,7 +919,7 @@ public sealed class MultilangController : ControllerBase .ToArray(); } - private static object DebugTextOutput(ResolvedText text) + private static object DebugTextOutput(ItemsProxyResolvedText text) => new { Source = text.Change ? "Multilang" : JellyfinAction, @@ -1432,22 +927,22 @@ public sealed class MultilangController : ControllerBase ClearsValue = text.Change && string.IsNullOrWhiteSpace(text.Value) }; - private static object DebugAssetOutput(ResolvedAsset asset) + private static object DebugAssetOutput(ItemsProxyResolvedAsset asset) => new { Source = asset.Change ? "Multilang" : JellyfinAction, - Value = asset.Change && !string.IsNullOrWhiteSpace(asset.Value) ? ToImageTag(asset.Value) : asset.Value, + 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 = GenreMediaForFacts(facts); + var media = ItemsProxyTransformer.GenreMediaForFacts(facts); if (media.Length == 0) return []; var namesById = _store.GetGenreNames(media, locale); - return ParseJsonIntArray(facts.GenreTmdbIdsJson) + return ItemsProxyTransformer.ParseJsonIntArray(facts.GenreTmdbIdsJson) .Select(id => { var hasName = namesById.TryGetValue(id, out var name); @@ -1463,20 +958,6 @@ public sealed class MultilangController : ControllerBase .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) { @@ -1735,18 +1216,6 @@ public sealed class MultilangController : ControllerBase return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical; } - 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 [JellyfinAction]; - } - private IActionResult ServeEmbedded(string resource, string contentType) { var content = ReadEmbedded(resource); diff --git a/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs b/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs index c2a4e4f..f07aa66 100644 --- a/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs +++ b/src/Jellyfin.Plugin.Multilang/PluginServiceRegistrator.cs @@ -26,6 +26,7 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs new file mode 100644 index 0000000..f325554 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs @@ -0,0 +1,575 @@ +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.Refresh; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using static Jellyfin.Plugin.Multilang.MultilangConstants; + +namespace Jellyfin.Plugin.Multilang.Services; + +public readonly record struct ItemsProxyTransformResult(string Body, string[] ItemIds); + +public readonly record struct ItemsProxyResolvedText(bool Change, string? Value); + +public readonly record struct ItemsProxyResolvedAsset(bool Change, string? Value); + +public readonly record struct ItemsProxyCategoryMatch( + UserCategoryRule? Matched, + UserCategoryRule? Effective, + bool DuplicateLabelResolved); + +public sealed class ItemsProxyTransformer +{ + private readonly TranslationStore _store; + private readonly ILibraryManager _libraryManager; + private readonly SortArticleCatalog _articleCatalog; + private readonly RefreshService _refreshService; + + public ItemsProxyTransformer( + TranslationStore store, + ILibraryManager libraryManager, + SortArticleCatalog articleCatalog, + RefreshService refreshService) + { + _store = store; + _libraryManager = libraryManager; + _articleCatalog = articleCatalog; + _refreshService = refreshService; + } + + public ItemsProxyTransformResult TransformItemsResponse(string body, UserRulesDocument rules, ItemsProxyControls controls) + { + JsonNode? root; + try + { + root = JsonNode.Parse(body); + } + catch (JsonException) + { + return new ItemsProxyTransformResult(body, []); + } + + if (root is null) + return new ItemsProxyTransformResult(body, []); + + var items = CollectItemObjects(root).ToArray(); + var ids = GetItemIds(items); + if (ids.Length == 0) + return new ItemsProxyTransformResult(body, ids); + + var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale); + var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale); + var sortArticles = ItemsProxySorting.GetSortArticles(_articleCatalog, Plugin.Instance?.Configuration?.ArticleEntries ?? [], sortLocale); + + var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.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 = GetItemIds(items); + } + + if (rules.Enabled) + ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem); + + ItemsProxySorting.Apply(root, controls, sortCulture, sortArticles); + return new ItemsProxyTransformResult(root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), ids); + } + + public bool TryBuildGenresResponse(string media, string locale, out string body) + { + var names = _store.GetGenreNames(media, locale); + if (names.Count == 0) + { + body = string.Empty; + return false; + } + + var culture = ItemsProxySorting.GetSortCulture(ItemsProxySorting.ResolveSortLocale(locale, locale)); + var comparer = StringComparer.Create(culture, true); + var items = names + .Where(kv => !string.IsNullOrWhiteSpace(kv.Value)) + .OrderBy(kv => kv.Value, comparer) + .Select(g => new JsonObject + { + ["Name"] = g.Value, + ["Id"] = "tmdb-" + g.Key.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; + } + + public 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(); + } + + public ItemsProxyCategoryMatch 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 ItemsProxyCategoryMatch(category, effective, !ReferenceEquals(category, effective)); + } + } + + return new ItemsProxyCategoryMatch(null, null, false); + } + + public 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))) + }; + } + + public 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; + + return category is not null && category.FieldActions is not null + ? LegacyActionToList(field, category.FieldActions) + : [JellyfinAction]; + } + + public static ItemsProxyResolvedText ResolveField( + string field, + IReadOnlyList actions, + FactsData facts, + Dictionary>? byLang) + { + foreach (var action in actions) + { + if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)) + return new ItemsProxyResolvedText(false, null); + if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) && + action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(facts.OriginalTitle)) + return new ItemsProxyResolvedText(true, facts.OriginalTitle); + if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) + { + var originalValue = GetTranslatedField(byLang, OriginalAction, field); + if (!string.IsNullOrWhiteSpace(originalValue)) + return new ItemsProxyResolvedText(true, originalValue); + continue; + } + + if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field); + if (!string.IsNullOrWhiteSpace(value)) + return new ItemsProxyResolvedText(true, value); + } + + return field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) + ? new ItemsProxyResolvedText(false, null) + : new ItemsProxyResolvedText(true, string.Empty); + } + + public static ItemsProxyResolvedAsset ResolveAsset( + string kind, + IReadOnlyList actions, + Dictionary>? byKind) + { + foreach (var action in actions) + { + if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase)) + return new ItemsProxyResolvedAsset(false, null); + if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) + { + var originalValue = GetAsset(byKind, kind, OriginalAction); + if (!string.IsNullOrWhiteSpace(originalValue)) + return new ItemsProxyResolvedAsset(true, originalValue); + continue; + } + + if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + var value = GetAsset(byKind, kind, action[LanguagePrefix.Length..]); + if (!string.IsNullOrWhiteSpace(value)) + return new ItemsProxyResolvedAsset(true, value); + } + + return kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase) + ? new ItemsProxyResolvedAsset(false, null) + : new ItemsProxyResolvedAsset(true, string.Empty); + } + + public 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; + + public 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; + + public static string ToImageTag(string url) + => "ml:" + Uri.EscapeDataString(url); + + public static int[] ParseJsonIntArray(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return []; + + try + { + return JsonSerializer.Deserialize(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + public static int CountRootArrayItems(string body) + { + try + { + var node = JsonNode.Parse(body); + return node is JsonArray array ? array.Count : 0; + } + catch (JsonException) + { + return 0; + } + } + + public 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; + } + + private void ApplyRules( + IEnumerable items, + string[] ids, + UserRulesDocument rules, + string clientLocale, + IReadOnlyDictionary factsByItem) + { + var langs = GetNeededLanguages(rules); + var translations = _store.GetTranslations(ids, langs); + var assets = _store.GetAssets(ids, langs); + var movieGenreNames = _store.GetGenreNames("movie", clientLocale); + var tvGenreNames = _store.GetGenreNames("tv", 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 = PickCategoryMatch(rules, classificationFacts).Effective; + + var title = ResolveField(TitleField, GetActionList(rules, category, TitleField), facts, byLang); + if (title.Change) + item["Name"] = title.Value ?? string.Empty; + + var overview = ResolveField(OverviewField, GetActionList(rules, category, OverviewField), facts, byLang); + if (overview.Change) + { + if (string.IsNullOrWhiteSpace(overview.Value)) + item.Remove("Overview"); + else + item["Overview"] = overview.Value; + } + + var tagline = ResolveField(TaglineField, GetActionList(rules, category, TaglineField), 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(PosterKind, GetActionList(rules, category, PosterKind), assetsByKind)); + ApplyImageAsset(item, "Logo", ResolveAsset(LogoKind, GetActionList(rules, category, LogoKind), assetsByKind)); + ApplyImageAsset(item, "Banner", ResolveAsset(BannerKind, GetActionList(rules, category, BannerKind), assetsByKind)); + ApplyImageAsset(item, "Thumb", ResolveAsset(ThumbKind, GetActionList(rules, category, ThumbKind), assetsByKind)); + ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind)); + ApplyGenres(item, facts, movieGenreNames, tvGenreNames); + } + } + + private static void ApplyImageAsset(JsonObject item, string imageType, ItemsProxyResolvedAsset 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, ItemsProxyResolvedAsset asset) + { + if (!asset.Change) + return; + + item["BackdropImageTags"] = string.IsNullOrWhiteSpace(asset.Value) + ? new JsonArray() + : new JsonArray(ToImageTag(asset.Value)); + } + + 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 genres = ParseJsonIntArray(facts.GenreTmdbIdsJson) + .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 void FilterRootByGenres(JsonNode root, IReadOnlyDictionary factsByItem, int[] genreIds) + { + var wanted = genreIds.ToHashSet(); + bool Matches(JsonObject item) + { + var itemId = TryGetItemId(item); + return itemId is not null && + factsByItem.TryGetValue(itemId, out var facts) && + 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 string[] GetItemIds(IEnumerable items) + => items + .Select(TryGetItemId) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + 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; + } + + 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(OriginalAction, StringComparison.OrdinalIgnoreCase)) + { + langs.Add(OriginalAction); + return; + } + + if (action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)) + langs.Add(action[LanguagePrefix.Length..]); + } + + 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 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); +}