Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs
T

609 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 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);
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<string>();
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<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 category is not null && category.FieldActions is not null
? LegacyActionToList(field, category.FieldActions)
: [JellyfinAction];
}
public static ItemsProxyResolvedText ResolveField(
string field,
IReadOnlyList<string> actions,
FactsData facts,
Dictionary<string, Dictionary<string, string>>? 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<string> actions,
Dictionary<string, Dictionary<string, string>>? 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<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 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 = 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<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[] LegacyActionToList(string field, IReadOnlyDictionary<string, string>? 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<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);
}