Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs
T
ajp_anton e36775f1a3 Release 0.2.5: harden proxy and streamline metadata fetching
Remove obsolete rule formats, batch SQLite work, preserve artwork source URLs, and coordinate refresh workers with maintenance. Add regression coverage and document backup format changes.
2026-09-09 01:18:43 +00:00

595 lines
24 KiB
C#

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 record ItemsProxyResolutionAttempt(
string Action, string Source, string LookupKey, bool HasValue, bool Chosen, string? Value, string Reason);
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<string, FactsData>(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<string, string>(StringComparer.OrdinalIgnoreCase);
var jellyfinTitleLanguages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var sortArticlesByLanguage = new Dictionary<string, string[]>(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);
if (!sortArticlesByLanguage.TryGetValue(language, out var articles))
{
articles = ItemsProxySorting.GetSortArticles(_articleCatalog, config.ArticleEntries, language, config.IgnoreArticlesWhenSorting);
sortArticlesByLanguage[language] = articles;
}
return articles;
});
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<string>();
foreach (var category in rules.Categories ?? [])
{
foreach (var action in GetAllActionTokens(category.FieldActionLists))
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)
=> GetClassificationFacts(rules, new Dictionary<string, FactsData> { [facts.ItemId] = facts })[facts.ItemId];
private IReadOnlyDictionary<string, FactsData> GetClassificationFacts(UserRulesDocument rules, IReadOnlyDictionary<string, FactsData> facts)
{
if (rules.TrustTmdbCollections)
return facts;
var children = facts.Values.Where(fact => fact.Kind == "collection")
.ToDictionary(fact => fact.ItemId, fact =>
(_libraryManager.GetItemById(Guid.Parse(fact.ItemId)) as Folder)?.GetLinkedChildren()
.OfType<MediaBrowser.Controller.Entities.Movies.Movie>()
.Select(child => TranslationStore.ToItemId32(child.Id)).ToArray() ?? []);
var childFacts = _store.GetFactsForItems(children.Values.SelectMany(ids => ids));
var result = new Dictionary<string, FactsData>(facts, StringComparer.OrdinalIgnoreCase);
foreach (var (id, ids) in children)
result[id] = AggregateCollectionFacts(facts[id], ids.Where(childFacts.ContainsKey).Select(id => childFacts[id]).ToArray());
return result;
}
public static FactsData AggregateCollectionFacts(FactsData facts, FactsData[] childFacts)
{
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<string> 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 [JellyfinAction];
}
public static ItemsProxyResolvedText ResolveField(
string field, IReadOnlyList<string> actions, FactsData facts,
Dictionary<string, Dictionary<string, string>>? byLang,
Action<ItemsProxyResolutionAttempt>? trace = null)
{
foreach (var action in actions)
{
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
{
trace?.Invoke(new(action, JellyfinAction, "", true, true, null, "Use Jellyfin value"));
return new(false, null);
}
var original = action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase);
if (!original && !action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
continue;
var lang = original ? OriginalAction : action[LanguagePrefix.Length..];
var originalTitle = original && field == TitleField && !string.IsNullOrWhiteSpace(facts.OriginalTitle);
var value = originalTitle ? facts.OriginalTitle : GetTranslatedField(byLang, lang, field);
var found = !string.IsNullOrWhiteSpace(value);
trace?.Invoke(new(action, originalTitle ? "OriginalTitle" : "Translation",
originalTitle ? "facts.original_title" : lang + "/" + field,
found, found, value, found ? "Value found" : "Value missing or empty"));
if (found)
return new(true, value, original ? facts.OriginalLanguage : lang);
}
return field == TitleField ? new(false, null) : new(true, string.Empty);
}
public static ItemsProxyResolvedAsset ResolveAsset(
string kind, IReadOnlyList<string> actions,
Dictionary<string, Dictionary<string, string>>? byKind,
Action<ItemsProxyResolutionAttempt>? trace = null)
{
foreach (var action in actions)
{
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
{
trace?.Invoke(new(action, JellyfinAction, "", true, true, null, "Use Jellyfin image"));
return new(false, null);
}
var original = action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase);
if (!original && !action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
continue;
var lang = original ? OriginalAction : action[LanguagePrefix.Length..];
var value = GetAsset(byKind, kind, lang);
var found = !string.IsNullOrWhiteSpace(value);
trace?.Invoke(new(action, "Asset", kind + "/" + lang, found, found, value,
found ? "Asset found" : "Asset missing"));
if (found)
return new(true, value);
}
return kind == PosterKind ? new(false, null) : new(true, string.Empty);
}
public static string? GetTranslatedField(
Dictionary<string, Dictionary<string, string>>? 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<string, Dictionary<string, string>>? 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<int[]>(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<string, string> ApplyRules(
IEnumerable<JsonObject> items,
string[] ids,
UserRulesDocument rules,
string clientLocale,
IReadOnlyDictionary<string, FactsData> factsByItem)
{
var langs = GetNeededLanguages(rules);
var translations = _store.GetTranslations(ids, langs);
var assets = _store.GetAssets(ids, langs);
var classification = GetClassificationFacts(rules, factsByItem);
var titleLanguages = new Dictionary<string, string>(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 = classification[itemId];
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<string, string> 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<int, string> movieGenreNames,
IReadOnlyDictionary<int, string> 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<string, FactsData> 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<JsonObject>().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<JsonObject>().Where(Matches).ToArray();
array.Clear();
foreach (var item in kept)
array.Add(item);
}
}
private static string[] GetItemIds(IEnumerable<JsonObject> items)
=> items
.Select(TryGetItemId)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static IEnumerable<JsonObject> CollectItemObjects(JsonNode root)
{
if (root is JsonArray array)
{
foreach (var item in array.OfType<JsonObject>())
yield return item;
yield break;
}
if (root is JsonObject obj)
{
if (obj["Items"] is JsonArray items)
{
foreach (var item in items.OfType<JsonObject>())
yield return item;
yield break;
}
yield return obj;
}
}
private static string? TryGetItemId(JsonObject item)
{
var id = item["Id"]?.GetValue<string>();
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<string> GetAllActionTokens(IReadOnlyDictionary<string, string[]>? actions)
=> actions is null ? [] : actions.Values.SelectMany(v => v ?? []);
private static void AddLanguageAction(List<string> 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 SharedSingle(IEnumerable<string> 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<string> 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);
}