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, string? Language = null); 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 config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale); var sortCulture = ItemsProxySorting.GetSortCulture(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); } var titleLanguages = rules.Enabled ? ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem) : new Dictionary(StringComparer.OrdinalIgnoreCase); var jellyfinTitleLanguages = new Dictionary(StringComparer.OrdinalIgnoreCase); ItemsProxySorting.Apply(root, controls, sortCulture, item => { var itemId = TryGetItemId(item); var language = itemId is not null && titleLanguages.TryGetValue(itemId, out var translatedLanguage) ? translatedLanguage : ResolveJellyfinTitleLanguage(itemId, config, jellyfinTitleLanguages); return ItemsProxySorting.GetSortArticles(_articleCatalog, config.ArticleEntries, language, config.IgnoreArticlesWhenSorting); }); 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, facts.OriginalLanguage); if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) { var originalValue = GetTranslatedField(byLang, OriginalAction, field); if (!string.IsNullOrWhiteSpace(originalValue)) return new ItemsProxyResolvedText(true, originalValue, facts.OriginalLanguage); 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, action[LanguagePrefix.Length..]); } 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 Dictionary 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 titleLanguages = new Dictionary(StringComparer.OrdinalIgnoreCase); 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; if (!string.IsNullOrWhiteSpace(title.Language)) titleLanguages[itemId] = title.Language; 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); } return titleLanguages; } private string ResolveJellyfinTitleLanguage( string? itemId, PluginConfiguration config, Dictionary resolvedLanguages) { if (string.IsNullOrWhiteSpace(itemId)) return config.JellyfinTitleLanguageFallback; if (resolvedLanguages.TryGetValue(itemId, out var existing)) return existing; var language = Guid.TryParse(itemId, out var itemGuid) ? _libraryManager.GetItemById(itemGuid)?.GetPreferredMetadataLanguage() : null; language = string.IsNullOrWhiteSpace(language) ? config.JellyfinTitleLanguageFallback : language; resolvedLanguages[itemId] = language ?? string.Empty; return resolvedLanguages[itemId]; } 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); }