Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs
T

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";
}