Extract ItemsProxy transformer
This commit is contained in:
@@ -47,6 +47,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
private readonly RefreshService _refreshService;
|
private readonly RefreshService _refreshService;
|
||||||
private readonly FanartClient _fanartClient;
|
private readonly FanartClient _fanartClient;
|
||||||
private readonly ItemsProxyCache _itemsProxyCache;
|
private readonly ItemsProxyCache _itemsProxyCache;
|
||||||
|
private readonly ItemsProxyTransformer _itemsProxyTransformer;
|
||||||
private readonly AssetStorageService _assetStorage;
|
private readonly AssetStorageService _assetStorage;
|
||||||
private readonly MultilangBackupService _backupService;
|
private readonly MultilangBackupService _backupService;
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
RefreshService refreshService,
|
RefreshService refreshService,
|
||||||
FanartClient fanartClient,
|
FanartClient fanartClient,
|
||||||
ItemsProxyCache itemsProxyCache,
|
ItemsProxyCache itemsProxyCache,
|
||||||
|
ItemsProxyTransformer itemsProxyTransformer,
|
||||||
AssetStorageService assetStorage,
|
AssetStorageService assetStorage,
|
||||||
MultilangBackupService backupService)
|
MultilangBackupService backupService)
|
||||||
{
|
{
|
||||||
@@ -78,6 +80,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
_refreshService = refreshService;
|
_refreshService = refreshService;
|
||||||
_fanartClient = fanartClient;
|
_fanartClient = fanartClient;
|
||||||
_itemsProxyCache = itemsProxyCache;
|
_itemsProxyCache = itemsProxyCache;
|
||||||
|
_itemsProxyTransformer = itemsProxyTransformer;
|
||||||
_assetStorage = assetStorage;
|
_assetStorage = assetStorage;
|
||||||
_backupService = backupService;
|
_backupService = backupService;
|
||||||
}
|
}
|
||||||
@@ -286,7 +289,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
return Content(cached.Body, cached.ContentType);
|
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();
|
totalSw.Stop();
|
||||||
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(genresBody);
|
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(genresBody);
|
||||||
@@ -295,7 +298,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
cacheHit: false,
|
cacheHit: false,
|
||||||
cacheStored: false,
|
cacheStored: false,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
itemCount: CountRootArrayItems(genresBody),
|
itemCount: ItemsProxyTransformer.CountRootArrayItems(genresBody),
|
||||||
totalMs: totalSw.ElapsedMilliseconds,
|
totalMs: totalSw.ElapsedMilliseconds,
|
||||||
upstreamMs: 0,
|
upstreamMs: 0,
|
||||||
transformMs: totalSw.ElapsedMilliseconds,
|
transformMs: totalSw.ElapsedMilliseconds,
|
||||||
@@ -316,7 +319,9 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (response.IsSuccessStatusCode && rules.Enabled)
|
if (response.IsSuccessStatusCode && rules.Enabled)
|
||||||
{
|
{
|
||||||
var transformSw = Stopwatch.StartNew();
|
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();
|
transformSw.Stop();
|
||||||
transformMs = transformSw.ElapsedMilliseconds;
|
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<string, FactsData>(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<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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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<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);
|
|
||||||
|
|
||||||
private readonly record struct ResolvedText(bool Change, string? Value);
|
|
||||||
|
|
||||||
private readonly record struct ResolvedAsset(bool Change, string? Value);
|
|
||||||
|
|
||||||
private sealed record DebugResolutionAttempt(
|
private sealed record DebugResolutionAttempt(
|
||||||
string Action,
|
string Action,
|
||||||
string Source,
|
string Source,
|
||||||
@@ -607,7 +395,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
string Field,
|
string Field,
|
||||||
string[] Actions,
|
string[] Actions,
|
||||||
DebugResolutionAttempt[] Attempts,
|
DebugResolutionAttempt[] Attempts,
|
||||||
ResolvedText Result,
|
ItemsProxyResolvedText Result,
|
||||||
string SelectedAction,
|
string SelectedAction,
|
||||||
string Reason);
|
string Reason);
|
||||||
|
|
||||||
@@ -615,303 +403,10 @@ public sealed class MultilangController : ControllerBase
|
|||||||
string Kind,
|
string Kind,
|
||||||
string[] Actions,
|
string[] Actions,
|
||||||
DebugResolutionAttempt[] Attempts,
|
DebugResolutionAttempt[] Attempts,
|
||||||
ResolvedAsset Result,
|
ItemsProxyResolvedAsset Result,
|
||||||
string SelectedAction,
|
string SelectedAction,
|
||||||
string Reason);
|
string Reason);
|
||||||
|
|
||||||
private readonly record struct CategoryMatch(
|
|
||||||
UserCategoryRule? Matched,
|
|
||||||
UserCategoryRule? Effective,
|
|
||||||
bool DuplicateLabelResolved);
|
|
||||||
|
|
||||||
private static ResolvedText 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 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<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;
|
|
||||||
|
|
||||||
private static ResolvedAsset 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 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<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;
|
|
||||||
|
|
||||||
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<int, string> movieGenreNames,
|
|
||||||
IReadOnlyDictionary<int, string> 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<string, FactsData> 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<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 int[] ParseJsonIntArray(string json)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
|
||||||
return [];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<int[]>(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<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("CacheEntries")]
|
[HttpGet("CacheEntries")]
|
||||||
public async Task<IActionResult> CacheEntries()
|
public async Task<IActionResult> CacheEntries()
|
||||||
{
|
{
|
||||||
@@ -1160,7 +655,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
|
|
||||||
var rules = GetRulesOrDefault(userId);
|
var rules = GetRulesOrDefault(userId);
|
||||||
var configuredLanguages = Plugin.Instance?.Configuration?.Languages ?? [];
|
var configuredLanguages = Plugin.Instance?.Configuration?.Languages ?? [];
|
||||||
var ruleLanguages = GetNeededLanguages(rules);
|
var ruleLanguages = _itemsProxyTransformer.GetNeededLanguages(rules);
|
||||||
var debugLanguages = configuredLanguages
|
var debugLanguages = configuredLanguages
|
||||||
.Concat(ruleLanguages)
|
.Concat(ruleLanguages)
|
||||||
.Append(OriginalAction)
|
.Append(OriginalAction)
|
||||||
@@ -1171,11 +666,11 @@ public sealed class MultilangController : ControllerBase
|
|||||||
var assets = _store.GetAssets([itemId32], debugLanguages);
|
var assets = _store.GetAssets([itemId32], debugLanguages);
|
||||||
translations.TryGetValue(itemId32, out var byLang);
|
translations.TryGetValue(itemId32, out var byLang);
|
||||||
assets.TryGetValue(itemId32, out var byKind);
|
assets.TryGetValue(itemId32, out var byKind);
|
||||||
var classificationFacts = GetClassificationFacts(rules, facts);
|
var classificationFacts = _itemsProxyTransformer.GetClassificationFacts(rules, facts);
|
||||||
var categoryTraces = (rules.Categories ?? [])
|
var categoryTraces = (rules.Categories ?? [])
|
||||||
.Select(category => CategoryRuleEvaluator.Trace(category, classificationFacts))
|
.Select(category => CategoryRuleEvaluator.Trace(category, classificationFacts))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var categoryMatch = PickCategoryMatch(rules, classificationFacts);
|
var categoryMatch = _itemsProxyTransformer.PickCategoryMatch(rules, classificationFacts);
|
||||||
var category = categoryMatch.Effective;
|
var category = categoryMatch.Effective;
|
||||||
var clientLocale = Request.Query.TryGetValue("mlLocale", out var mlLocale) ? mlLocale.ToString() : string.Empty;
|
var clientLocale = Request.Query.TryGetValue("mlLocale", out var mlLocale) ? mlLocale.ToString() : string.Empty;
|
||||||
var effectiveSortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, clientLocale);
|
var effectiveSortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, clientLocale);
|
||||||
@@ -1183,7 +678,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
|
|
||||||
var actionLists = ActionFields.ToDictionary(
|
var actionLists = ActionFields.ToDictionary(
|
||||||
field => field,
|
field => field,
|
||||||
field => GetActionList(rules, category, field).ToArray(),
|
field => ItemsProxyTransformer.GetActionList(rules, category, field).ToArray(),
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var title = TraceResolveField(TitleField, actionLists[TitleField], facts, byLang);
|
var title = TraceResolveField(TitleField, actionLists[TitleField], facts, byLang);
|
||||||
@@ -1282,7 +777,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
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"));
|
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");
|
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"));
|
attempts.Add(new DebugResolutionAttempt(action, "OriginalTitle", "facts.original_title", hasOriginalTitle, hasOriginalTitle, facts.OriginalTitle, hasOriginalTitle ? "Original title found" : "Original title missing"));
|
||||||
if (hasOriginalTitle)
|
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");
|
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))
|
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var originalValue = GetTranslatedField(byLang, OriginalAction, field);
|
var originalValue = ItemsProxyTransformer.GetTranslatedField(byLang, OriginalAction, field);
|
||||||
var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue);
|
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"));
|
attempts.Add(new DebugResolutionAttempt(action, "Translation", "Original/" + field, hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language translation found" : "Original-language translation missing"));
|
||||||
if (hasOriginalValue)
|
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");
|
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 lang = action[LanguagePrefix.Length..];
|
||||||
var value = GetTranslatedField(byLang, lang, field);
|
var value = ItemsProxyTransformer.GetTranslatedField(byLang, lang, field);
|
||||||
var hasValue = !string.IsNullOrWhiteSpace(value);
|
var hasValue = !string.IsNullOrWhiteSpace(value);
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Translation", lang + "/" + field, hasValue, hasValue, value, hasValue ? "Translation found" : "Translation missing or empty"));
|
attempts.Add(new DebugResolutionAttempt(action, "Translation", lang + "/" + field, hasValue, hasValue, value, hasValue ? "Translation found" : "Translation missing or empty"));
|
||||||
if (hasValue)
|
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");
|
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Translation found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fallback = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
var fallback = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
||||||
? new ResolvedText(false, null)
|
? new ItemsProxyResolvedText(false, null)
|
||||||
: new ResolvedText(true, string.Empty);
|
: new ItemsProxyResolvedText(true, string.Empty);
|
||||||
var reason = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
var reason = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
||||||
? "No action had data; title falls back to Jellyfin"
|
? "No action had data; title falls back to Jellyfin"
|
||||||
: "No action had data; field is cleared";
|
: "No action had data; field is cleared";
|
||||||
@@ -1351,19 +846,19 @@ public sealed class MultilangController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
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"));
|
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");
|
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var originalValue = GetAsset(byKind, kind, OriginalAction);
|
var originalValue = ItemsProxyTransformer.GetAsset(byKind, kind, OriginalAction);
|
||||||
var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue);
|
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"));
|
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/Original", hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language asset found" : "Original-language asset missing"));
|
||||||
if (hasOriginalValue)
|
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");
|
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 lang = action[LanguagePrefix.Length..];
|
||||||
var value = GetAsset(byKind, kind, lang);
|
var value = ItemsProxyTransformer.GetAsset(byKind, kind, lang);
|
||||||
var hasValue = !string.IsNullOrWhiteSpace(value);
|
var hasValue = !string.IsNullOrWhiteSpace(value);
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/" + lang, hasValue, hasValue, value, hasValue ? "Asset found" : "Asset missing"));
|
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/" + lang, hasValue, hasValue, value, hasValue ? "Asset found" : "Asset missing"));
|
||||||
if (hasValue)
|
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");
|
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Asset found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fallback = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
var fallback = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
||||||
? new ResolvedAsset(false, null)
|
? new ItemsProxyResolvedAsset(false, null)
|
||||||
: new ResolvedAsset(true, string.Empty);
|
: new ItemsProxyResolvedAsset(true, string.Empty);
|
||||||
var reason = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
var reason = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
||||||
? "No action had data; poster falls back to Jellyfin"
|
? "No action had data; poster falls back to Jellyfin"
|
||||||
: "No action had data; image is cleared";
|
: "No action had data; image is cleared";
|
||||||
@@ -1408,10 +903,10 @@ public sealed class MultilangController : ControllerBase
|
|||||||
.Select(lang =>
|
.Select(lang =>
|
||||||
{
|
{
|
||||||
var missingText = TextFields
|
var missingText = TextFields
|
||||||
.Where(field => string.IsNullOrWhiteSpace(GetTranslatedField(byLang, lang, field)))
|
.Where(field => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetTranslatedField(byLang, lang, field)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var missingAssets = AssetKinds
|
var missingAssets = AssetKinds
|
||||||
.Where(kind => string.IsNullOrWhiteSpace(GetAsset(byKind, kind, lang)))
|
.Where(kind => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetAsset(byKind, kind, lang)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
@@ -1424,7 +919,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object DebugTextOutput(ResolvedText text)
|
private static object DebugTextOutput(ItemsProxyResolvedText text)
|
||||||
=> new
|
=> new
|
||||||
{
|
{
|
||||||
Source = text.Change ? "Multilang" : JellyfinAction,
|
Source = text.Change ? "Multilang" : JellyfinAction,
|
||||||
@@ -1432,22 +927,22 @@ public sealed class MultilangController : ControllerBase
|
|||||||
ClearsValue = text.Change && string.IsNullOrWhiteSpace(text.Value)
|
ClearsValue = text.Change && string.IsNullOrWhiteSpace(text.Value)
|
||||||
};
|
};
|
||||||
|
|
||||||
private static object DebugAssetOutput(ResolvedAsset asset)
|
private static object DebugAssetOutput(ItemsProxyResolvedAsset asset)
|
||||||
=> new
|
=> new
|
||||||
{
|
{
|
||||||
Source = asset.Change ? "Multilang" : JellyfinAction,
|
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)
|
ClearsValue = asset.Change && string.IsNullOrWhiteSpace(asset.Value)
|
||||||
};
|
};
|
||||||
|
|
||||||
private object[] ResolveGenresForDebug(FactsData facts, string locale)
|
private object[] ResolveGenresForDebug(FactsData facts, string locale)
|
||||||
{
|
{
|
||||||
var media = GenreMediaForFacts(facts);
|
var media = ItemsProxyTransformer.GenreMediaForFacts(facts);
|
||||||
if (media.Length == 0)
|
if (media.Length == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var namesById = _store.GetGenreNames(media, locale);
|
var namesById = _store.GetGenreNames(media, locale);
|
||||||
return ParseJsonIntArray(facts.GenreTmdbIdsJson)
|
return ItemsProxyTransformer.ParseJsonIntArray(facts.GenreTmdbIdsJson)
|
||||||
.Select(id =>
|
.Select(id =>
|
||||||
{
|
{
|
||||||
var hasName = namesById.TryGetValue(id, out var name);
|
var hasName = namesById.TryGetValue(id, out var name);
|
||||||
@@ -1463,20 +958,6 @@ public sealed class MultilangController : ControllerBase
|
|||||||
.ToArray();
|
.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")]
|
[HttpPost("TestProvider")]
|
||||||
public async Task<IActionResult> TestProvider([FromBody] TestProviderRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> TestProvider([FromBody] TestProviderRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -1735,18 +1216,6 @@ public sealed class MultilangController : ControllerBase
|
|||||||
return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical;
|
return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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;
|
|
||||||
|
|
||||||
if (category is not null && category.FieldActions is not null)
|
|
||||||
return LegacyActionToList(field, category.FieldActions);
|
|
||||||
|
|
||||||
return [JellyfinAction];
|
|
||||||
}
|
|
||||||
|
|
||||||
private IActionResult ServeEmbedded(string resource, string contentType)
|
private IActionResult ServeEmbedded(string resource, string contentType)
|
||||||
{
|
{
|
||||||
var content = ReadEmbedded(resource);
|
var content = ReadEmbedded(resource);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
services.AddSingleton<RefreshCoordinator>();
|
services.AddSingleton<RefreshCoordinator>();
|
||||||
services.AddSingleton<RefreshService>();
|
services.AddSingleton<RefreshService>();
|
||||||
services.AddSingleton<ItemsProxyCache>();
|
services.AddSingleton<ItemsProxyCache>();
|
||||||
|
services.AddSingleton<ItemsProxyTransformer>();
|
||||||
services.AddSingleton<AssetStorageService>();
|
services.AddSingleton<AssetStorageService>();
|
||||||
services.AddSingleton<MultilangBackupService>();
|
services.AddSingleton<MultilangBackupService>();
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, FactsData>(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<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);
|
||||||
|
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<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 void 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 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<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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user