1517 lines
64 KiB
C#
1517 lines
64 KiB
C#
using System.Reflection;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using static Jellyfin.Plugin.Multilang.MultilangConstants;
|
|
using Jellyfin.Plugin.Multilang.Configuration;
|
|
using Jellyfin.Plugin.Multilang.Data;
|
|
using Jellyfin.Plugin.Multilang.Rules;
|
|
using Jellyfin.Plugin.Multilang.Services;
|
|
using Jellyfin.Plugin.Multilang.Services.Assets;
|
|
using Jellyfin.Plugin.Multilang.Services.Backup;
|
|
using Jellyfin.Plugin.Multilang.Services.Providers;
|
|
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
|
using MediaBrowser.Common.Configuration;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Controller.Net;
|
|
using MediaBrowser.Controller.Session;
|
|
using MediaBrowser.Model.Globalization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace Jellyfin.Plugin.Multilang.Api;
|
|
|
|
[ApiController]
|
|
[Route("Multilang")]
|
|
public sealed class MultilangController : ControllerBase
|
|
{
|
|
private static readonly string[] RequirementFieldOrder =
|
|
[
|
|
"original_language",
|
|
"spoken_languages",
|
|
"audio_language",
|
|
"origin_countries",
|
|
"production_countries"
|
|
];
|
|
|
|
private readonly TranslationStore _store;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly IAuthorizationContext _authorizationContext;
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly IUserManager _userManager;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly IApplicationPaths _appPaths;
|
|
private readonly ILocalizationManager _localizationManager;
|
|
private readonly ProviderCatalog _providerCatalog;
|
|
private readonly SortArticleCatalog _articleCatalog;
|
|
private readonly RefreshService _refreshService;
|
|
private readonly FanartClient _fanartClient;
|
|
private readonly ItemsProxyCache _itemsProxyCache;
|
|
private readonly ItemsProxyTransformer _itemsProxyTransformer;
|
|
private readonly AssetStorageService _assetStorage;
|
|
private readonly MultilangBackupService _backupService;
|
|
|
|
public MultilangController(
|
|
TranslationStore store,
|
|
ISessionManager sessionManager,
|
|
IAuthorizationContext authorizationContext,
|
|
ILibraryManager libraryManager,
|
|
IUserManager userManager,
|
|
IHttpClientFactory httpClientFactory,
|
|
IApplicationPaths appPaths,
|
|
ILocalizationManager localizationManager,
|
|
ProviderCatalog providerCatalog,
|
|
SortArticleCatalog articleCatalog,
|
|
RefreshService refreshService,
|
|
FanartClient fanartClient,
|
|
ItemsProxyCache itemsProxyCache,
|
|
ItemsProxyTransformer itemsProxyTransformer,
|
|
AssetStorageService assetStorage,
|
|
MultilangBackupService backupService)
|
|
{
|
|
_store = store;
|
|
_sessionManager = sessionManager;
|
|
_authorizationContext = authorizationContext;
|
|
_libraryManager = libraryManager;
|
|
_userManager = userManager;
|
|
_httpClientFactory = httpClientFactory;
|
|
_appPaths = appPaths;
|
|
_localizationManager = localizationManager;
|
|
_providerCatalog = providerCatalog;
|
|
_articleCatalog = articleCatalog;
|
|
_refreshService = refreshService;
|
|
_fanartClient = fanartClient;
|
|
_itemsProxyCache = itemsProxyCache;
|
|
_itemsProxyTransformer = itemsProxyTransformer;
|
|
_assetStorage = assetStorage;
|
|
_backupService = backupService;
|
|
}
|
|
|
|
[HttpGet("inject.js")]
|
|
public IActionResult InjectJs()
|
|
=> ServeEmbedded("Jellyfin.Plugin.Multilang.inject.js", "application/javascript");
|
|
|
|
[HttpGet("shared.js")]
|
|
public IActionResult SharedJs()
|
|
=> ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.js", "application/javascript");
|
|
|
|
[HttpGet("shared.css")]
|
|
public IActionResult SharedCss()
|
|
=> ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css");
|
|
|
|
[HttpGet("Assets/{**path}")]
|
|
public IActionResult Asset(string path)
|
|
=> _assetStorage.TryResolveLocalAsset(path, out var fullPath, out var contentType)
|
|
? PhysicalFile(fullPath, contentType)
|
|
: NotFound();
|
|
|
|
[HttpGet("Providers")]
|
|
public IActionResult Providers()
|
|
=> Ok(_providerCatalog.Providers);
|
|
|
|
[HttpGet("ArticleBuiltins")]
|
|
public IActionResult ArticleBuiltins()
|
|
=> Ok(_articleCatalog.GetBuiltIns().OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(k => k.Key, v => v.Value));
|
|
|
|
[HttpGet("MetadataLanguages")]
|
|
public IActionResult MetadataLanguages()
|
|
=> Ok(_localizationManager.GetCultures()
|
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
|
.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
|
|
.Select(c => new { c.Name, c.DisplayName }));
|
|
|
|
[HttpGet("AdminConfig")]
|
|
public async Task<IActionResult> AdminConfig()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
|
return Ok(AdminConfigDto.FromConfiguration(cfg));
|
|
}
|
|
|
|
[HttpPost("AdminConfig")]
|
|
public async Task<IActionResult> SaveAdminConfig([FromBody] AdminConfigDto request)
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
var plugin = Plugin.Instance;
|
|
if (plugin is null)
|
|
return StatusCode(500, new { Error = "PluginUnavailable" });
|
|
|
|
var previous = plugin.Configuration;
|
|
var cfg = request.ToConfiguration(_providerCatalog);
|
|
plugin.UpdateConfiguration(cfg);
|
|
if (AdminConfigAffectsCachedItems(previous, cfg))
|
|
_itemsProxyCache.ClearAll();
|
|
|
|
return Ok(AdminConfigDto.FromConfiguration(cfg));
|
|
}
|
|
|
|
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
|
|
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
|
|
!string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.OrdinalIgnoreCase) ||
|
|
previous.IgnoreArticlesWhenSorting != next.IgnoreArticlesWhenSorting ||
|
|
!string.Equals(previous.JellyfinTitleLanguageFallback, next.JellyfinTitleLanguageFallback, StringComparison.OrdinalIgnoreCase) ||
|
|
!ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries);
|
|
|
|
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
|
|
=> previous.OrderBy(v => v, comparer).SequenceEqual(next.OrderBy(v => v, comparer), comparer);
|
|
|
|
private static bool ArticleEntriesEqual(SortArticleEntry[] previous, SortArticleEntry[] next)
|
|
{
|
|
var previousEntries = previous
|
|
.OrderBy(e => e.Language, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(e => e.Articles, StringComparer.Ordinal)
|
|
.ThenBy(e => e.AlwaysApply)
|
|
.ToArray();
|
|
var nextEntries = next
|
|
.OrderBy(e => e.Language, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(e => e.Articles, StringComparer.Ordinal)
|
|
.ThenBy(e => e.AlwaysApply)
|
|
.ToArray();
|
|
|
|
if (previousEntries.Length != nextEntries.Length)
|
|
return false;
|
|
|
|
for (var i = 0; i < previousEntries.Length; i++)
|
|
{
|
|
if (!previousEntries[i].Language.Equals(nextEntries[i].Language, StringComparison.OrdinalIgnoreCase) ||
|
|
!previousEntries[i].Articles.Equals(nextEntries[i].Articles, StringComparison.Ordinal) ||
|
|
previousEntries[i].AlwaysApply != nextEntries[i].AlwaysApply)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
[HttpGet("UserRules/self")]
|
|
public async Task<IActionResult> GetSelfRules()
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
return Ok(GetRulesOrDefault(userId));
|
|
}
|
|
|
|
[HttpPost("UserRules/self")]
|
|
public async Task<IActionResult> SaveSelfRules([FromBody] UserRulesDocument request)
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
var allowed = (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
request.Categories = NormalizeCategories(request.Categories, allowed);
|
|
request.FallbackFieldActions = NormalizeActionLists(request.FallbackFieldActions, allowed);
|
|
_store.SaveUserRules(userId, request);
|
|
_itemsProxyCache.ClearAll();
|
|
return Ok(request);
|
|
}
|
|
|
|
[HttpGet("UserRulesUi")]
|
|
public IActionResult UserRulesUi()
|
|
{
|
|
Response.Headers["Content-Security-Policy"] =
|
|
"default-src 'self'; base-uri 'self'; form-action 'self'; img-src 'self' data:; " +
|
|
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self';";
|
|
var fragment = ReadEmbedded("Jellyfin.Plugin.Multilang.Configuration.userRulesPage.html");
|
|
if (fragment is null)
|
|
return NotFound();
|
|
return Content("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Multilang User Rules</title></head><body>" + fragment + "</body></html>", "text/html");
|
|
}
|
|
|
|
[HttpGet("DebugUi/{itemId}")]
|
|
public IActionResult DebugUi(string itemId)
|
|
{
|
|
Response.Headers["Content-Security-Policy"] =
|
|
"default-src 'self'; base-uri 'self'; form-action 'self'; img-src 'self' data:; " +
|
|
"style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self';";
|
|
var fragment = ReadEmbedded("Jellyfin.Plugin.Multilang.Configuration.debugPage.html");
|
|
if (fragment is null)
|
|
return NotFound();
|
|
return Content("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Multilang Debug</title></head><body>" + fragment + "</body></html>", "text/html");
|
|
}
|
|
|
|
[HttpGet("AllowedLanguages")]
|
|
public async Task<IActionResult> AllowedLanguages()
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
return Ok(new { Languages = Plugin.Instance?.Configuration?.Languages ?? [] });
|
|
}
|
|
|
|
[HttpPost("RefreshItem/{itemId}")]
|
|
public async Task<IActionResult> RefreshItem(string itemId, [FromQuery] bool includeChildren = true, CancellationToken cancellationToken = default)
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
if (!TryParseItemId(itemId, out _, out var itemGuid))
|
|
return BadRequest(new { Error = "InvalidItemId" });
|
|
|
|
await _refreshService.RefreshItemAsync(itemGuid, includeChildren, cancellationToken).ConfigureAwait(false);
|
|
return Ok(new { ItemId = itemGuid.ToString("N"), IncludeChildren = includeChildren });
|
|
}
|
|
|
|
[HttpGet("ItemsProxy")]
|
|
public async Task<IActionResult> ItemsProxy([FromQuery] string url, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
return BadRequest(new { Error = "MissingUrl" });
|
|
|
|
var token = GetTokenFromRequest();
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
var rules = GetRulesOrDefault(userId);
|
|
var proxyRequest = ItemsProxyRequestBuilder.Build(Request, url, token, userId, rules.Enabled);
|
|
if (proxyRequest is null)
|
|
return BadRequest(new { Error = "InvalidUrl" });
|
|
|
|
var totalSw = Stopwatch.StartNew();
|
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
|
var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0;
|
|
var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes));
|
|
if (cacheAllowed && _itemsProxyCache.TryGet(proxyRequest.CacheKey, cacheTtl, out var cached))
|
|
{
|
|
totalSw.Stop();
|
|
_itemsProxyCache.RecordRequest(
|
|
proxyRequest.NormalizedUrlForCache,
|
|
cacheHit: true,
|
|
cacheStored: false,
|
|
statusCode: 200,
|
|
itemCount: cached.ItemCount,
|
|
totalMs: totalSw.ElapsedMilliseconds,
|
|
upstreamMs: 0,
|
|
transformMs: 0,
|
|
sizeBytes: cached.SizeBytes);
|
|
return Content(cached.Body, cached.ContentType);
|
|
}
|
|
|
|
if (rules.Enabled && proxyRequest.IsGenresRequest && _itemsProxyTransformer.TryBuildGenresResponse(proxyRequest.GenreMedia, proxyRequest.Controls.ClientLocale, out var genresBody))
|
|
{
|
|
totalSw.Stop();
|
|
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(genresBody);
|
|
_itemsProxyCache.RecordRequest(
|
|
proxyRequest.NormalizedUrlForCache,
|
|
cacheHit: false,
|
|
cacheStored: false,
|
|
statusCode: 200,
|
|
itemCount: ItemsProxyTransformer.CountRootArrayItems(genresBody),
|
|
totalMs: totalSw.ElapsedMilliseconds,
|
|
upstreamMs: 0,
|
|
transformMs: totalSw.ElapsedMilliseconds,
|
|
sizeBytes: sizeBytes);
|
|
return Content(genresBody, "application/json");
|
|
}
|
|
|
|
var upstreamSw = Stopwatch.StartNew();
|
|
var http = _httpClientFactory.CreateClient();
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream);
|
|
request.Headers.TryAddWithoutValidation("X-Emby-Token", token);
|
|
request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token);
|
|
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
upstreamSw.Stop();
|
|
string[] responseItemIds = [];
|
|
var transformMs = 0L;
|
|
if (response.IsSuccessStatusCode && rules.Enabled)
|
|
{
|
|
var transformSw = Stopwatch.StartNew();
|
|
var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls);
|
|
body = transformed.Body;
|
|
responseItemIds = transformed.ItemIds;
|
|
transformSw.Stop();
|
|
transformMs = transformSw.ElapsedMilliseconds;
|
|
}
|
|
totalSw.Stop();
|
|
|
|
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/json";
|
|
var bodySizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
|
|
var cacheStored = false;
|
|
if (cacheAllowed &&
|
|
response.IsSuccessStatusCode &&
|
|
responseItemIds.Length > 0 &&
|
|
totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs))
|
|
{
|
|
cacheStored = _itemsProxyCache.Store(
|
|
proxyRequest.CacheKey,
|
|
userId,
|
|
proxyRequest.NormalizedUrlForCache,
|
|
responseItemIds,
|
|
body,
|
|
contentType,
|
|
totalSw.ElapsedMilliseconds,
|
|
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L,
|
|
cacheTtl);
|
|
}
|
|
|
|
if (cacheAllowed &&
|
|
!cacheStored &&
|
|
response.IsSuccessStatusCode &&
|
|
responseItemIds.Length > 0)
|
|
{
|
|
_itemsProxyCache.StoreMicro(
|
|
proxyRequest.CacheKey,
|
|
userId,
|
|
proxyRequest.NormalizedUrlForCache,
|
|
responseItemIds,
|
|
body,
|
|
contentType,
|
|
totalSw.ElapsedMilliseconds,
|
|
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L);
|
|
}
|
|
|
|
_itemsProxyCache.RecordRequest(
|
|
proxyRequest.NormalizedUrlForCache,
|
|
cacheHit: false,
|
|
cacheStored: cacheStored,
|
|
statusCode: (int)response.StatusCode,
|
|
itemCount: responseItemIds.Length,
|
|
totalMs: totalSw.ElapsedMilliseconds,
|
|
upstreamMs: upstreamSw.ElapsedMilliseconds,
|
|
transformMs: transformMs,
|
|
sizeBytes: bodySizeBytes);
|
|
|
|
return new ContentResult
|
|
{
|
|
StatusCode = (int)response.StatusCode,
|
|
Content = body,
|
|
ContentType = contentType
|
|
};
|
|
}
|
|
|
|
private sealed record DebugResolutionAttempt(
|
|
string Action,
|
|
string Source,
|
|
string LookupKey,
|
|
bool HasValue,
|
|
bool Chosen,
|
|
string? Value,
|
|
string Reason);
|
|
|
|
private sealed record DebugTextResolution(
|
|
string Field,
|
|
string[] Actions,
|
|
DebugResolutionAttempt[] Attempts,
|
|
ItemsProxyResolvedText Result,
|
|
string SelectedAction,
|
|
string Reason);
|
|
|
|
private sealed record DebugAssetResolution(
|
|
string Kind,
|
|
string[] Actions,
|
|
DebugResolutionAttempt[] Attempts,
|
|
ItemsProxyResolvedAsset Result,
|
|
string SelectedAction,
|
|
string Reason);
|
|
|
|
[HttpGet("CacheEntries")]
|
|
public async Task<IActionResult> CacheEntries()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
return Ok(_itemsProxyCache.GetEntries());
|
|
}
|
|
|
|
[HttpPost("ClearCache")]
|
|
public async Task<IActionResult> ClearCache()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
_itemsProxyCache.ClearAll();
|
|
return Ok(new { Cleared = true });
|
|
}
|
|
|
|
[HttpGet("ItemsProxyRequests")]
|
|
public async Task<IActionResult> ItemsProxyRequests()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
return Ok(_itemsProxyCache.GetRecentRequests());
|
|
}
|
|
|
|
[HttpGet("RefreshDiagnostics")]
|
|
public async Task<IActionResult> RefreshDiagnostics()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
return Ok(_refreshService.GetDiagnostics());
|
|
}
|
|
|
|
[HttpGet("ExportInfo")]
|
|
public async Task<IActionResult> ExportInfo()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
var storage = _backupService.GetStorageInfo();
|
|
return Ok(new
|
|
{
|
|
storage.DatabaseBytes,
|
|
storage.AssetsBytes,
|
|
CleanupDataOnUninstall = Plugin.Instance?.Configuration?.CleanupDataOnUninstall == true,
|
|
AssetStorageMode = Plugin.Instance?.Configuration?.AssetStorageMode ?? "url"
|
|
});
|
|
}
|
|
|
|
[HttpGet("Export")]
|
|
public async Task<IActionResult> Export(
|
|
[FromQuery] bool pluginSettings = true,
|
|
[FromQuery] bool userSettings = true,
|
|
[FromQuery] bool translationsDatabase = false,
|
|
[FromQuery] bool downloadedAssets = false)
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
if (downloadedAssets && !translationsDatabase)
|
|
return BadRequest(new { Error = "AssetsRequireDatabase" });
|
|
|
|
var bytes = _backupService.Export(
|
|
new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets),
|
|
Plugin.Instance?.Configuration ?? new PluginConfiguration());
|
|
var name = "multilang-export-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".zip";
|
|
return File(bytes, "application/zip", name);
|
|
}
|
|
|
|
[HttpPost("Import")]
|
|
public async Task<IActionResult> Import()
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
|
var file = form.Files.GetFile("file");
|
|
if (file is null || file.Length == 0)
|
|
return BadRequest(new { Error = "MissingFile" });
|
|
|
|
var options = new BackupImportOptions(
|
|
FormBool(form, "pluginSettings", true),
|
|
FormBool(form, "userSettings", true),
|
|
FormBool(form, "translationsDatabase", true),
|
|
FormBool(form, "downloadedAssets", true));
|
|
if (options.DownloadedAssets && !options.TranslationsDatabase)
|
|
return BadRequest(new { Error = "AssetsRequireDatabase" });
|
|
|
|
try
|
|
{
|
|
await using var stream = file.OpenReadStream();
|
|
var result = _backupService.ImportAdmin(
|
|
stream,
|
|
options,
|
|
Plugin.Instance?.Configuration ?? new PluginConfiguration(),
|
|
cfg => Plugin.Instance?.UpdateConfiguration(cfg));
|
|
_itemsProxyCache.ClearAll();
|
|
return Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return BadRequest(new { Error = ex.Message });
|
|
}
|
|
}
|
|
|
|
[HttpPost("CleanupAll")]
|
|
public async Task<IActionResult> CleanupAll([FromBody] CleanupAllRequest request)
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
if (request.Confirm != true)
|
|
return BadRequest(new { Error = "ConfirmationRequired" });
|
|
|
|
Plugin.Instance?.UpdateConfiguration(new PluginConfiguration());
|
|
_store.ResetAll();
|
|
_itemsProxyCache.ClearAll();
|
|
return Ok(new { Cleared = true });
|
|
}
|
|
|
|
[HttpGet("UserRules/self/export")]
|
|
public async Task<IActionResult> ExportSelfRules()
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
var bytes = _backupService.ExportUserRules(userId, GetRulesOrDefault(userId));
|
|
var name = "multilang-user-rules-" + userId + ".zip";
|
|
return File(bytes, "application/zip", name);
|
|
}
|
|
|
|
[HttpPost("UserRules/self/import/inspect")]
|
|
public async Task<IActionResult> InspectSelfRulesImport()
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
|
var file = form.Files.GetFile("file");
|
|
if (file is null || file.Length == 0)
|
|
return BadRequest(new { Error = "MissingFile" });
|
|
|
|
try
|
|
{
|
|
await using var stream = file.OpenReadStream();
|
|
return Ok(_backupService.InspectUserImport(stream, userId));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return BadRequest(new { Error = ex.Message });
|
|
}
|
|
}
|
|
|
|
[HttpPost("UserRules/self/import")]
|
|
public async Task<IActionResult> ImportSelfRules()
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
var form = await Request.ReadFormAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
|
var file = form.Files.GetFile("file");
|
|
if (file is null || file.Length == 0)
|
|
return BadRequest(new { Error = "MissingFile" });
|
|
|
|
try
|
|
{
|
|
await using var stream = file.OpenReadStream();
|
|
var result = _backupService.ImportUser(stream, userId, form.TryGetValue("sourceUserId", out var sourceUserId) ? sourceUserId.ToString() : null);
|
|
if (result.UserSettingsImported > 0)
|
|
_itemsProxyCache.ClearAll();
|
|
return Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return BadRequest(new { Error = ex.Message });
|
|
}
|
|
}
|
|
|
|
[HttpGet("Debug/{itemId}")]
|
|
public async Task<IActionResult> DebugItem(string itemId)
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
if (!TryParseItemId(itemId, out var itemId32, out _))
|
|
return BadRequest(new { Error = "InvalidItemId" });
|
|
|
|
return Ok(new
|
|
{
|
|
ItemId = itemId32,
|
|
Facts = _store.GetFacts(itemId32),
|
|
Translations = _store.GetTranslationsForDebug(itemId32),
|
|
Rules = GetRulesOrDefault(userId)
|
|
});
|
|
}
|
|
|
|
[HttpGet("Debug/{itemId}/self")]
|
|
public async Task<IActionResult> DebugItemForSelf(string itemId)
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
return DebugResolveForUser(itemId, userId);
|
|
}
|
|
|
|
[HttpGet("Debug/{itemId}/{userId}")]
|
|
public async Task<IActionResult> DebugItemForUser(string itemId, string userId)
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Error = "AdminRequired" });
|
|
|
|
var normalizedUserId = NormalizeUserId(userId);
|
|
if (normalizedUserId is null)
|
|
return BadRequest(new { Error = "InvalidUserId" });
|
|
|
|
return DebugResolveForUser(itemId, normalizedUserId);
|
|
}
|
|
|
|
[HttpGet("DebugResolve/{itemId}")]
|
|
public async Task<IActionResult> DebugResolveItem(string itemId)
|
|
{
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
|
|
|
return DebugResolveForUser(itemId, userId);
|
|
}
|
|
|
|
private IActionResult DebugResolveForUser(string itemId, string userId)
|
|
{
|
|
if (!TryParseItemId(itemId, out var itemId32, out _))
|
|
return BadRequest(new { Error = "InvalidItemId" });
|
|
|
|
var facts = _store.GetFacts(itemId32);
|
|
if (facts is null)
|
|
return NotFound(new { Error = "FactsMissing" });
|
|
|
|
var rules = GetRulesOrDefault(userId);
|
|
var configuredLanguages = Plugin.Instance?.Configuration?.Languages ?? [];
|
|
var ruleLanguages = _itemsProxyTransformer.GetNeededLanguages(rules);
|
|
var debugLanguages = configuredLanguages
|
|
.Concat(ruleLanguages)
|
|
.Append(OriginalAction)
|
|
.Where(lang => !string.IsNullOrWhiteSpace(lang))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var translations = _store.GetTranslations([itemId32], debugLanguages);
|
|
var assets = _store.GetAssets([itemId32], debugLanguages);
|
|
translations.TryGetValue(itemId32, out var byLang);
|
|
assets.TryGetValue(itemId32, out var byKind);
|
|
var classificationFacts = _itemsProxyTransformer.GetClassificationFacts(rules, facts);
|
|
var categoryTraces = (rules.Categories ?? [])
|
|
.Select(category => CategoryRuleEvaluator.Trace(category, classificationFacts))
|
|
.ToArray();
|
|
var categoryMatch = _itemsProxyTransformer.PickCategoryMatch(rules, classificationFacts);
|
|
var category = categoryMatch.Effective;
|
|
var clientLocale = Request.Query.TryGetValue("mlLocale", out var mlLocale) ? mlLocale.ToString() : string.Empty;
|
|
var effectiveSortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, clientLocale);
|
|
var genreLocale = string.IsNullOrWhiteSpace(clientLocale) ? effectiveSortLocale : clientLocale;
|
|
|
|
var actionLists = ActionFields.ToDictionary(
|
|
field => field,
|
|
field => ItemsProxyTransformer.GetActionList(rules, category, field).ToArray(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var title = TraceResolveField(TitleField, actionLists[TitleField], facts, byLang);
|
|
var overview = TraceResolveField(OverviewField, actionLists[OverviewField], facts, byLang);
|
|
var tagline = TraceResolveField(TaglineField, actionLists[TaglineField], facts, byLang);
|
|
var poster = TraceResolveAsset(PosterKind, actionLists[PosterKind], byKind);
|
|
var logo = TraceResolveAsset(LogoKind, actionLists[LogoKind], byKind);
|
|
var banner = TraceResolveAsset(BannerKind, actionLists[BannerKind], byKind);
|
|
var thumb = TraceResolveAsset(ThumbKind, actionLists[ThumbKind], byKind);
|
|
var backdrop = TraceResolveAsset(BackdropKind, actionLists[BackdropKind], byKind);
|
|
var genres = ResolveGenresForDebug(facts, genreLocale);
|
|
|
|
return Ok(new
|
|
{
|
|
UserId = userId,
|
|
ItemId = itemId32,
|
|
RulesEnabled = rules.Enabled,
|
|
SortLocale = rules.SortLocale,
|
|
ClientLocale = clientLocale,
|
|
EffectiveSortLocale = effectiveSortLocale,
|
|
GenreLocale = genreLocale,
|
|
ConfiguredLanguages = configuredLanguages,
|
|
RuleLanguages = ruleLanguages,
|
|
DebugLanguages = debugLanguages,
|
|
MatchedCategory = categoryMatch.Matched ?? new { Id = "fallback", Label = FallbackAction } as object,
|
|
EffectiveCategory = category ?? new { Id = "fallback", Label = FallbackAction } as object,
|
|
DuplicateCategoryLabelResolved = categoryMatch.DuplicateLabelResolved,
|
|
CategoryEvaluation = new
|
|
{
|
|
UsesCollectionAggregateFacts = !rules.TrustTmdbCollections &&
|
|
facts.Kind.Equals("collection", StringComparison.OrdinalIgnoreCase),
|
|
CategoryTraces = categoryTraces,
|
|
FirstMatchedTrace = categoryTraces.FirstOrDefault(trace => trace.Result)
|
|
},
|
|
FieldActions = actionLists,
|
|
FieldResolution = new
|
|
{
|
|
Title = title,
|
|
Overview = overview,
|
|
Tagline = tagline,
|
|
Poster = poster,
|
|
Logo = logo,
|
|
Banner = banner,
|
|
Thumb = thumb,
|
|
Backdrop = backdrop
|
|
},
|
|
MissingData = BuildMissingDataReport(configuredLanguages, byLang, byKind),
|
|
Facts = facts,
|
|
ClassificationFacts = classificationFacts,
|
|
ResolvedText = new
|
|
{
|
|
Title = title.Result,
|
|
Overview = overview.Result,
|
|
Tagline = tagline.Result
|
|
},
|
|
ResolvedAssets = new
|
|
{
|
|
Poster = poster.Result,
|
|
Logo = logo.Result,
|
|
Banner = banner.Result,
|
|
Thumb = thumb.Result,
|
|
Backdrop = backdrop.Result
|
|
},
|
|
ResolvedGenres = genres,
|
|
FinalOutput = new
|
|
{
|
|
Name = DebugTextOutput(title.Result),
|
|
Overview = DebugTextOutput(overview.Result),
|
|
Tagline = DebugTextOutput(tagline.Result),
|
|
ImageTags = new
|
|
{
|
|
Primary = DebugAssetOutput(poster.Result),
|
|
Logo = DebugAssetOutput(logo.Result),
|
|
Banner = DebugAssetOutput(banner.Result),
|
|
Thumb = DebugAssetOutput(thumb.Result)
|
|
},
|
|
BackdropImageTags = DebugAssetOutput(backdrop.Result),
|
|
Genres = genres
|
|
},
|
|
TranslationLanguages = byLang?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToArray() ?? [],
|
|
AssetKinds = byKind?.ToDictionary(
|
|
kind => kind.Key,
|
|
kind => kind.Value.Keys.OrderBy(lang => lang, StringComparer.OrdinalIgnoreCase).ToArray(),
|
|
StringComparer.OrdinalIgnoreCase) ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
});
|
|
}
|
|
|
|
private static DebugTextResolution TraceResolveField(
|
|
string field,
|
|
IReadOnlyList<string> actions,
|
|
FactsData facts,
|
|
Dictionary<string, Dictionary<string, string>>? byLang)
|
|
{
|
|
var attempts = new List<DebugResolutionAttempt>();
|
|
foreach (var action in actions)
|
|
{
|
|
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var result = new ItemsProxyResolvedText(false, null);
|
|
attempts.Add(new DebugResolutionAttempt(action, JellyfinAction, string.Empty, true, true, null, "Use Jellyfin value"));
|
|
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached");
|
|
}
|
|
|
|
if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) &&
|
|
action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var hasOriginalTitle = !string.IsNullOrWhiteSpace(facts.OriginalTitle);
|
|
attempts.Add(new DebugResolutionAttempt(action, "OriginalTitle", "facts.original_title", hasOriginalTitle, hasOriginalTitle, facts.OriginalTitle, hasOriginalTitle ? "Original title found" : "Original title missing"));
|
|
if (hasOriginalTitle)
|
|
{
|
|
var result = new ItemsProxyResolvedText(true, facts.OriginalTitle);
|
|
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original title found");
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var originalValue = ItemsProxyTransformer.GetTranslatedField(byLang, OriginalAction, field);
|
|
var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue);
|
|
attempts.Add(new DebugResolutionAttempt(action, "Translation", "Original/" + field, hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language translation found" : "Original-language translation missing"));
|
|
if (hasOriginalValue)
|
|
{
|
|
var result = new ItemsProxyResolvedText(true, originalValue);
|
|
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original-language translation found");
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token"));
|
|
continue;
|
|
}
|
|
|
|
var lang = action[LanguagePrefix.Length..];
|
|
var value = ItemsProxyTransformer.GetTranslatedField(byLang, lang, field);
|
|
var hasValue = !string.IsNullOrWhiteSpace(value);
|
|
attempts.Add(new DebugResolutionAttempt(action, "Translation", lang + "/" + field, hasValue, hasValue, value, hasValue ? "Translation found" : "Translation missing or empty"));
|
|
if (hasValue)
|
|
{
|
|
var result = new ItemsProxyResolvedText(true, value);
|
|
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Translation found");
|
|
}
|
|
}
|
|
|
|
var fallback = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
|
? new ItemsProxyResolvedText(false, null)
|
|
: new ItemsProxyResolvedText(true, string.Empty);
|
|
var reason = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
|
? "No action had data; title falls back to Jellyfin"
|
|
: "No action had data; field is cleared";
|
|
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), fallback, field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) ? JellyfinAction : ClearAction, reason);
|
|
}
|
|
|
|
private static DebugAssetResolution TraceResolveAsset(
|
|
string kind,
|
|
IReadOnlyList<string> actions,
|
|
Dictionary<string, Dictionary<string, string>>? byKind)
|
|
{
|
|
var attempts = new List<DebugResolutionAttempt>();
|
|
foreach (var action in actions)
|
|
{
|
|
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var result = new ItemsProxyResolvedAsset(false, null);
|
|
attempts.Add(new DebugResolutionAttempt(action, JellyfinAction, string.Empty, true, true, null, "Use Jellyfin image"));
|
|
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached");
|
|
}
|
|
|
|
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var originalValue = ItemsProxyTransformer.GetAsset(byKind, kind, OriginalAction);
|
|
var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue);
|
|
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/Original", hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language asset found" : "Original-language asset missing"));
|
|
if (hasOriginalValue)
|
|
{
|
|
var result = new ItemsProxyResolvedAsset(true, originalValue);
|
|
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Original-language asset found");
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token"));
|
|
continue;
|
|
}
|
|
|
|
var lang = action[LanguagePrefix.Length..];
|
|
var value = ItemsProxyTransformer.GetAsset(byKind, kind, lang);
|
|
var hasValue = !string.IsNullOrWhiteSpace(value);
|
|
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/" + lang, hasValue, hasValue, value, hasValue ? "Asset found" : "Asset missing"));
|
|
if (hasValue)
|
|
{
|
|
var result = new ItemsProxyResolvedAsset(true, value);
|
|
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Asset found");
|
|
}
|
|
}
|
|
|
|
var fallback = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
|
? new ItemsProxyResolvedAsset(false, null)
|
|
: new ItemsProxyResolvedAsset(true, string.Empty);
|
|
var reason = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
|
? "No action had data; poster falls back to Jellyfin"
|
|
: "No action had data; image is cleared";
|
|
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), fallback, kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase) ? JellyfinAction : ClearAction, reason);
|
|
}
|
|
|
|
private static object[] BuildMissingDataReport(
|
|
IEnumerable<string> configuredLanguages,
|
|
Dictionary<string, Dictionary<string, string>>? byLang,
|
|
Dictionary<string, Dictionary<string, string>>? byKind)
|
|
{
|
|
return configuredLanguages
|
|
.Where(lang => !string.IsNullOrWhiteSpace(lang))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(lang => lang, StringComparer.OrdinalIgnoreCase)
|
|
.Select(lang =>
|
|
{
|
|
var missingText = TextFields
|
|
.Where(field => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetTranslatedField(byLang, lang, field)))
|
|
.ToArray();
|
|
var missingAssets = AssetKinds
|
|
.Where(kind => string.IsNullOrWhiteSpace(ItemsProxyTransformer.GetAsset(byKind, kind, lang)))
|
|
.ToArray();
|
|
return new
|
|
{
|
|
Language = lang,
|
|
MissingTextFields = missingText,
|
|
MissingAssetKinds = missingAssets,
|
|
Complete = missingText.Length == 0 && missingAssets.Length == 0
|
|
} as object;
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
private static object DebugTextOutput(ItemsProxyResolvedText text)
|
|
=> new
|
|
{
|
|
Source = text.Change ? "Multilang" : JellyfinAction,
|
|
Value = text.Value,
|
|
ClearsValue = text.Change && string.IsNullOrWhiteSpace(text.Value)
|
|
};
|
|
|
|
private static object DebugAssetOutput(ItemsProxyResolvedAsset asset)
|
|
=> new
|
|
{
|
|
Source = asset.Change ? "Multilang" : JellyfinAction,
|
|
Value = asset.Change && !string.IsNullOrWhiteSpace(asset.Value) ? ItemsProxyTransformer.ToImageTag(asset.Value) : asset.Value,
|
|
ClearsValue = asset.Change && string.IsNullOrWhiteSpace(asset.Value)
|
|
};
|
|
|
|
private object[] ResolveGenresForDebug(FactsData facts, string locale)
|
|
{
|
|
var media = ItemsProxyTransformer.GenreMediaForFacts(facts);
|
|
if (media.Length == 0)
|
|
return [];
|
|
|
|
var namesById = _store.GetGenreNames(media, locale);
|
|
return ItemsProxyTransformer.ParseJsonIntArray(facts.GenreTmdbIdsJson)
|
|
.Select(id =>
|
|
{
|
|
var hasName = namesById.TryGetValue(id, out var name);
|
|
return new
|
|
{
|
|
Id = "tmdb-" + id.ToString(CultureInfo.InvariantCulture),
|
|
TmdbId = id,
|
|
Media = media,
|
|
Name = name ?? string.Empty,
|
|
HasLocalizedName = hasName
|
|
} as object;
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
[HttpPost("TestProvider")]
|
|
public async Task<IActionResult> TestProvider([FromBody] TestProviderRequest request, CancellationToken cancellationToken)
|
|
{
|
|
if (!await IsRequesterAdminAsync().ConfigureAwait(false))
|
|
return Unauthorized(new { Ok = false, Error = "AdminRequired" });
|
|
|
|
var provider = (request.Provider ?? string.Empty).Trim().ToLowerInvariant();
|
|
var apiKey = (request.ApiKey ?? string.Empty).Trim();
|
|
if (apiKey.Length == 0)
|
|
return Ok(new { Ok = false, Error = "MissingApiKey" });
|
|
|
|
var http = _httpClientFactory.CreateClient();
|
|
if (provider == "tmdb")
|
|
{
|
|
using var response = await http.GetAsync($"https://api.themoviedb.org/3/configuration?api_key={Uri.EscapeDataString(apiKey)}", cancellationToken).ConfigureAwait(false);
|
|
return Ok(new { Ok = response.IsSuccessStatusCode, Status = (int)response.StatusCode });
|
|
}
|
|
|
|
if (provider == "fanart")
|
|
return Ok(new { Ok = await _fanartClient.TestApiKeyAsync(apiKey, cancellationToken).ConfigureAwait(false) });
|
|
|
|
return BadRequest(new { Ok = false, Error = "UnknownProvider" });
|
|
}
|
|
|
|
private UserRulesDocument GetRulesOrDefault(string userId)
|
|
{
|
|
var existing = _store.GetUserRules(userId);
|
|
if (existing is not null)
|
|
{
|
|
existing.Categories ??= [];
|
|
existing.FallbackFieldActions = NormalizeActionLists(existing.FallbackFieldActions, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase));
|
|
existing.Categories = NormalizeCategories(existing.Categories, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase));
|
|
return existing;
|
|
}
|
|
|
|
return new UserRulesDocument
|
|
{
|
|
Enabled = false,
|
|
SortLocale = "Auto",
|
|
TrustTmdbCollections = true,
|
|
FallbackFieldActions = DefaultActionLists(),
|
|
Categories = []
|
|
};
|
|
}
|
|
|
|
private static UserCategoryRule[] NormalizeCategories(IEnumerable<UserCategoryRule>? categories, IReadOnlySet<string> allowedLanguages)
|
|
=> (categories ?? [])
|
|
.Select(category => new UserCategoryRule
|
|
{
|
|
Id = string.IsNullOrWhiteSpace(category.Id) ? Guid.NewGuid().ToString("N") : category.Id.Trim(),
|
|
Label = string.IsNullOrWhiteSpace(category.Label) ? "Category" : category.Label.Trim(),
|
|
CriteriaText = category.CriteriaText?.Trim() ?? string.Empty,
|
|
Requirements = NormalizeRequirements(category.Requirements),
|
|
MatchAllConditions = category.MatchAllConditions,
|
|
Scopes = (category.Scopes ?? [])
|
|
.Where(scope => scope is "M" or "S" or "C")
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.DefaultIfEmpty("M")
|
|
.ToArray(),
|
|
FieldActions = NormalizeFieldActions(category.FieldActions, allowedLanguages),
|
|
FieldActionLists = NormalizeActionLists(category.FieldActionLists, allowedLanguages, category.FieldActions)
|
|
})
|
|
.ToArray();
|
|
|
|
private static UserCategoryRequirement[] NormalizeRequirements(IEnumerable<UserCategoryRequirement>? requirements)
|
|
=> (requirements ?? [])
|
|
.Select(requirement =>
|
|
{
|
|
var fields = NormalizeRequirementFields(requirement.Fields, requirement.Field);
|
|
return new UserCategoryRequirement
|
|
{
|
|
Fields = fields,
|
|
Field = fields.FirstOrDefault() ?? string.Empty,
|
|
UseFieldOr = requirement.UseFieldOr,
|
|
Relation = NormalizeRequirementRelation(requirement.Relation),
|
|
UseOr = requirement.UseOr,
|
|
Values = (requirement.Values ?? [])
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Select(v => v.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray()
|
|
};
|
|
})
|
|
.Where(requirement =>
|
|
requirement.Fields.Length > 0 &&
|
|
requirement.Relation.Length > 0 &&
|
|
requirement.Values.Length > 0)
|
|
.ToArray();
|
|
|
|
private static string[] NormalizeRequirementFields(IEnumerable<string>? fields, string? legacyField)
|
|
{
|
|
var requested = (fields ?? [])
|
|
.Append(legacyField ?? string.Empty)
|
|
.Select(NormalizeRequirementField)
|
|
.Where(field => field.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var result = new List<string>();
|
|
string? kind = null;
|
|
foreach (var field in RequirementFieldOrder)
|
|
{
|
|
if (!requested.Contains(field))
|
|
continue;
|
|
|
|
var currentKind = IsCountryRequirementField(field) ? "country" : "language";
|
|
kind ??= currentKind;
|
|
if (currentKind == kind)
|
|
result.Add(field);
|
|
}
|
|
|
|
return result.ToArray();
|
|
}
|
|
|
|
private static string NormalizeRequirementField(string? field)
|
|
{
|
|
var normalized = (field ?? string.Empty).Trim().ToLowerInvariant();
|
|
return normalized is "original_language" or "spoken_languages" or "audio_language" or "origin_countries" or "production_countries"
|
|
? normalized
|
|
: string.Empty;
|
|
}
|
|
|
|
private static bool IsCountryRequirementField(string field)
|
|
=> field.Equals("origin_countries", StringComparison.OrdinalIgnoreCase) ||
|
|
field.Equals("production_countries", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string NormalizeRequirementRelation(string? relation)
|
|
{
|
|
var normalized = (relation ?? string.Empty).Trim().ToLowerInvariant();
|
|
return normalized is "is" or "is_not" or "contains" or "not_contains"
|
|
? normalized
|
|
: string.Empty;
|
|
}
|
|
|
|
private static Dictionary<string, string[]> NormalizeActionLists(
|
|
IReadOnlyDictionary<string, string[]>? actions,
|
|
IReadOnlySet<string> allowedLanguages,
|
|
IReadOnlyDictionary<string, string>? legacyActions = null)
|
|
{
|
|
if (legacyActions is not null && LooksLikeDefaultActionLists(actions) && HasMeaningfulLegacyActions(legacyActions))
|
|
actions = null;
|
|
|
|
var result = DefaultActionLists();
|
|
foreach (var field in result.Keys.ToArray())
|
|
{
|
|
var raw = actions is not null && actions.TryGetValue(field, out var configured)
|
|
? configured
|
|
: LegacyActionToList(field, legacyActions);
|
|
result[field] = NormalizeActionList(field, raw, allowedLanguages);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static bool LooksLikeDefaultActionLists(IReadOnlyDictionary<string, string[]>? actions)
|
|
=> actions is null ||
|
|
DefaultActionLists().Keys.All(field =>
|
|
actions.TryGetValue(field, out var list) &&
|
|
list.Length == 1 &&
|
|
list[0].Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static bool HasMeaningfulLegacyActions(IReadOnlyDictionary<string, string> legacyActions)
|
|
=> legacyActions.Values.Any(action =>
|
|
!string.IsNullOrWhiteSpace(action) &&
|
|
!action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase) &&
|
|
!action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase));
|
|
|
|
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 Dictionary<string, string> NormalizeFieldActions(
|
|
IReadOnlyDictionary<string, string>? actions,
|
|
IReadOnlySet<string> allowedLanguages)
|
|
{
|
|
var result = DefaultFieldActions();
|
|
if (actions is null)
|
|
return result;
|
|
|
|
foreach (var field in result.Keys.ToArray())
|
|
{
|
|
if (!actions.TryGetValue(field, out var raw))
|
|
continue;
|
|
|
|
result[field] = NormalizeAction(field, raw, allowedLanguages);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string NormalizeAction(string field, string? raw, IReadOnlySet<string> allowedLanguages)
|
|
{
|
|
var action = (raw ?? string.Empty).Trim();
|
|
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase) ||
|
|
action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase))
|
|
return action;
|
|
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
return OriginalAction;
|
|
if ((field is OverviewField or TaglineField or LogoKind) &&
|
|
action.Equals(ClearAction, StringComparison.OrdinalIgnoreCase))
|
|
return ClearAction;
|
|
if (action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var lang = action[LanguagePrefix.Length..].Trim();
|
|
var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase));
|
|
if (!string.IsNullOrWhiteSpace(canonical))
|
|
return LanguagePrefix + canonical;
|
|
}
|
|
|
|
return FallbackAction;
|
|
}
|
|
|
|
private static string[] NormalizeActionList(string field, IEnumerable<string>? raw, IReadOnlySet<string> allowedLanguages)
|
|
{
|
|
var result = new List<string>();
|
|
foreach (var action in raw ?? [])
|
|
{
|
|
var normalized = NormalizeActionToken(field, action, allowedLanguages);
|
|
if (normalized.Length == 0)
|
|
continue;
|
|
if (result.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
|
continue;
|
|
result.Add(normalized);
|
|
}
|
|
|
|
if (RequiresJellyfinFallback(field))
|
|
{
|
|
if (!result.Contains(JellyfinAction, StringComparer.OrdinalIgnoreCase))
|
|
result.Add(JellyfinAction);
|
|
}
|
|
|
|
return result.ToArray();
|
|
}
|
|
|
|
private static string NormalizeActionToken(string field, string? raw, IReadOnlySet<string> allowedLanguages)
|
|
{
|
|
var action = (raw ?? string.Empty).Trim();
|
|
if (action.Equals("J", StringComparison.OrdinalIgnoreCase))
|
|
return JellyfinAction;
|
|
if (action.Equals("O", StringComparison.OrdinalIgnoreCase))
|
|
return OriginalAction;
|
|
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
|
return JellyfinAction;
|
|
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
return OriginalAction;
|
|
|
|
var lang = action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)
|
|
? action[LanguagePrefix.Length..].Trim()
|
|
: action;
|
|
var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase));
|
|
return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical;
|
|
}
|
|
|
|
private IActionResult ServeEmbedded(string resource, string contentType)
|
|
{
|
|
var content = ReadEmbedded(resource);
|
|
if (content is null)
|
|
return NotFound();
|
|
Response.Headers["Cache-Control"] = "no-cache";
|
|
return Content(content, contentType);
|
|
}
|
|
|
|
private static string? ReadEmbedded(string resource)
|
|
{
|
|
var asm = Assembly.GetExecutingAssembly();
|
|
using var stream = asm.GetManifestResourceStream(resource);
|
|
if (stream is null)
|
|
return null;
|
|
using var reader = new StreamReader(stream);
|
|
return reader.ReadToEnd();
|
|
}
|
|
|
|
private async Task<string?> GetUserIdAsync()
|
|
{
|
|
var info = await _authorizationContext.GetAuthorizationInfo(HttpContext).ConfigureAwait(false);
|
|
if (info is not null && info.IsAuthenticated)
|
|
{
|
|
if (info.User is not null)
|
|
return info.User.Id.ToString("N");
|
|
if (info.UserId != Guid.Empty)
|
|
return info.UserId.ToString("N");
|
|
}
|
|
|
|
var token = GetTokenFromRequest();
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
return null;
|
|
|
|
var tokenUserId = await GetUserIdFromJellyfinApiAsync(token).ConfigureAwait(false);
|
|
if (!string.IsNullOrWhiteSpace(tokenUserId))
|
|
return tokenUserId;
|
|
|
|
var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
|
|
var deviceId = info?.DeviceId ?? string.Empty;
|
|
var session = await _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint).ConfigureAwait(false);
|
|
if (session is not null)
|
|
return session.UserId.ToString("N");
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? NormalizeUserId(string userId)
|
|
{
|
|
userId = (userId ?? string.Empty).Trim();
|
|
if (Guid.TryParse(userId, out var guid))
|
|
return guid.ToString("N");
|
|
|
|
return userId.Length == 32 && userId.All(Uri.IsHexDigit)
|
|
? userId.ToLowerInvariant()
|
|
: null;
|
|
}
|
|
|
|
private async Task<string?> GetUserIdFromJellyfinApiAsync(string token)
|
|
{
|
|
var baseUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
|
var usersMe = new UriBuilder(new Uri(new Uri(baseUri), "/Users/Me"));
|
|
usersMe.Query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(
|
|
string.Empty,
|
|
"api_key",
|
|
token).TrimStart('?');
|
|
|
|
var http = _httpClientFactory.CreateClient();
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, usersMe.Uri);
|
|
request.Headers.TryAddWithoutValidation("X-Emby-Token", token);
|
|
request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token);
|
|
using var response = await http.SendAsync(request, HttpContext.RequestAborted).ConfigureAwait(false);
|
|
if (!response.IsSuccessStatusCode)
|
|
return null;
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
|
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: HttpContext.RequestAborted).ConfigureAwait(false);
|
|
if (!doc.RootElement.TryGetProperty("Id", out var idProperty))
|
|
return null;
|
|
|
|
var raw = idProperty.GetString();
|
|
return Guid.TryParse(raw, out var guid)
|
|
? guid.ToString("N")
|
|
: raw?.Length == 32 && raw.All(Uri.IsHexDigit) ? raw.ToLowerInvariant() : null;
|
|
}
|
|
|
|
private async Task<bool> IsRequesterAdminAsync()
|
|
{
|
|
var info = await _authorizationContext.GetAuthorizationInfo(HttpContext).ConfigureAwait(false);
|
|
if (info?.User is not null && TryGetIsAdmin(info.User, out var authInfoAdmin))
|
|
return authInfoAdmin;
|
|
|
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(userId) || !Guid.TryParseExact(userId, "N", out var guid))
|
|
return false;
|
|
|
|
var user = _userManager.GetUserById(guid);
|
|
if (user is null)
|
|
return false;
|
|
|
|
if (TryGetIsAdmin(user, out var isAdmin))
|
|
return isAdmin;
|
|
|
|
var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
|
|
var dto = _userManager.GetUserDto(user, remoteEndpoint);
|
|
return dto.Policy?.IsAdministrator == true;
|
|
}
|
|
|
|
private static bool FormBool(IFormCollection form, string key, bool fallback)
|
|
{
|
|
if (!form.TryGetValue(key, out var values))
|
|
return fallback;
|
|
var value = values.ToString();
|
|
return value.Equals("1", StringComparison.OrdinalIgnoreCase) ||
|
|
value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
|
|
value.Equals("on", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool TryGetIsAdmin(object user, out bool isAdmin)
|
|
{
|
|
isAdmin = false;
|
|
|
|
var policy = user.GetType().GetProperty("Policy")?.GetValue(user);
|
|
var policyAdmin = policy?.GetType().GetProperty("IsAdministrator")?.GetValue(policy);
|
|
if (policyAdmin is bool isPolicyAdmin)
|
|
{
|
|
isAdmin = isPolicyAdmin;
|
|
return true;
|
|
}
|
|
|
|
var directAdmin = user.GetType().GetProperty("IsAdministrator")?.GetValue(user);
|
|
if (directAdmin is bool isDirectAdmin)
|
|
{
|
|
isAdmin = isDirectAdmin;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private string? GetTokenFromRequest()
|
|
{
|
|
var req = HttpContext?.Request;
|
|
if (req is null)
|
|
return null;
|
|
|
|
if (req.Query.TryGetValue("token", out var token) && !string.IsNullOrWhiteSpace(token))
|
|
return token.ToString();
|
|
if (req.Query.TryGetValue("api_key", out var apiKey) && !string.IsNullOrWhiteSpace(apiKey))
|
|
return apiKey.ToString();
|
|
|
|
var headerToken = req.Headers["X-Emby-Token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(headerToken))
|
|
return headerToken;
|
|
headerToken = req.Headers["X-MediaBrowser-Token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(headerToken))
|
|
return headerToken;
|
|
|
|
var auth = req.Headers.Authorization.ToString();
|
|
const string marker = "Token=\"";
|
|
var idx = auth.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
return null;
|
|
var start = idx + marker.Length;
|
|
var end = auth.IndexOf('"', start);
|
|
return end > start ? auth[start..end] : null;
|
|
}
|
|
|
|
private static bool TryParseItemId(string itemId, out string itemId32, out Guid itemGuid)
|
|
{
|
|
itemId32 = string.Empty;
|
|
itemGuid = default;
|
|
if (Guid.TryParse(itemId, out var guid))
|
|
{
|
|
itemGuid = guid;
|
|
itemId32 = TranslationStore.ToItemId32(guid);
|
|
return true;
|
|
}
|
|
|
|
if (itemId.Length == 32 && itemId.All(Uri.IsHexDigit))
|
|
{
|
|
itemId32 = itemId.ToLowerInvariant();
|
|
itemGuid = Guid.ParseExact(itemId32, "N");
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public sealed record TestProviderRequest(string Provider, string ApiKey);
|
|
|
|
public sealed record CleanupAllRequest(bool Confirm);
|
|
|
|
public sealed class AdminConfigDto
|
|
{
|
|
public string TmdbApiKey { get; set; } = string.Empty;
|
|
|
|
public string FanartApiKey { get; set; } = string.Empty;
|
|
|
|
public string[] Languages { get; set; } = [];
|
|
|
|
public ProviderState[] Providers { get; set; } = [];
|
|
|
|
public int WaitDaysForMissingData { get; set; }
|
|
|
|
public int WaitDaysForExistingData { get; set; }
|
|
|
|
public int ItemsProxyCacheThresholdMs { get; set; }
|
|
|
|
public int ItemsProxyCacheTtlMinutes { get; set; }
|
|
|
|
public int ItemsProxyCacheMaxMiB { get; set; }
|
|
|
|
public string AssetStorageMode { get; set; } = "url";
|
|
|
|
public bool IgnoreArticlesWhenSorting { get; set; } = true;
|
|
|
|
public string JellyfinTitleLanguageFallback { get; set; } = string.Empty;
|
|
|
|
public SortArticleEntry[] ArticleEntries { get; set; } = [];
|
|
|
|
public bool EnableLogging { get; set; }
|
|
|
|
public bool VerboseLogging { get; set; }
|
|
|
|
public bool CleanupDataOnUninstall { get; set; }
|
|
|
|
public static AdminConfigDto FromConfiguration(PluginConfiguration cfg)
|
|
=> new()
|
|
{
|
|
TmdbApiKey = cfg.TmdbApiKey,
|
|
FanartApiKey = cfg.FanartApiKey,
|
|
Languages = cfg.Languages,
|
|
Providers = cfg.Providers,
|
|
WaitDaysForMissingData = cfg.WaitDaysForMissingData,
|
|
WaitDaysForExistingData = cfg.WaitDaysForExistingData,
|
|
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
|
|
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
|
|
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
|
|
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
|
|
IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting,
|
|
JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback,
|
|
ArticleEntries = cfg.ArticleEntries,
|
|
EnableLogging = cfg.EnableLogging,
|
|
VerboseLogging = cfg.VerboseLogging,
|
|
CleanupDataOnUninstall = cfg.CleanupDataOnUninstall
|
|
};
|
|
|
|
public PluginConfiguration ToConfiguration(ProviderCatalog providerCatalog)
|
|
=> new()
|
|
{
|
|
TmdbApiKey = TmdbApiKey?.Trim() ?? string.Empty,
|
|
FanartApiKey = FanartApiKey?.Trim() ?? string.Empty,
|
|
Languages = NormalizeLanguages(Languages),
|
|
Providers = Providers
|
|
.Where(p => providerCatalog.IsKnown(p.Id))
|
|
.Select(p => new ProviderState { Id = p.Id.Trim().ToLowerInvariant(), MetadataOrder = p.MetadataOrder, ArtworkOrder = p.ArtworkOrder })
|
|
.ToArray(),
|
|
WaitDaysForMissingData = Math.Max(0, WaitDaysForMissingData),
|
|
WaitDaysForExistingData = Math.Max(0, WaitDaysForExistingData),
|
|
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
|
|
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
|
|
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
|
|
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
|
|
IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting,
|
|
JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty,
|
|
ArticleEntries = ArticleEntries
|
|
.Where(e => !string.IsNullOrWhiteSpace(e.Language))
|
|
.Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply })
|
|
.ToArray(),
|
|
EnableLogging = EnableLogging || VerboseLogging,
|
|
VerboseLogging = VerboseLogging,
|
|
CleanupDataOnUninstall = CleanupDataOnUninstall
|
|
};
|
|
|
|
private static string[] NormalizeLanguages(IEnumerable<string> languages)
|
|
=> languages.Select(l => (l ?? string.Empty).Trim())
|
|
.Where(l => l.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
private static string NormalizeAssetStorageMode(string? value)
|
|
=> string.Equals(value?.Trim(), "local", StringComparison.OrdinalIgnoreCase) ? "local" : "url";
|
|
}
|