10 Commits
Author SHA1 Message Date
ajp_anton 096c6be8c7 Release 0.3.4: Jellyfin 12 stable support and audit fixes
Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
2026-09-09 01:18:43 +00:00
ajp_anton 8f7462fd3c Fix user settings on older clients 2026-08-21 23:20:59 +00:00
ajp_anton d365f7b58b Fix settings page compatibility and add debug access 2026-08-02 15:28:45 +00:00
ajp_anton 094d5ad9f0 Expand integration coverage and fix settings UI 2026-07-21 00:42:44 +00:00
ajp_anton 25088ce91d Improve proxy cache behavior and diagnostics 2026-07-20 02:17:33 +00:00
ajp_anton fd1cf12b6b Release version 0.3.1 2026-07-19 01:39:11 +00:00
ajp_anton 3c82485a0a Add language-aware article sorting 2026-07-19 01:35:06 +00:00
ajp_anton 8c79cfd3e6 Use standard auth headers for Jellyfin 12 2026-07-16 00:50:12 +00:00
ajp_anton a6acccfa66 Retarget plugin to Jellyfin 12 RC 2026-07-16 00:42:52 +00:00
ajp_anton e38ccf7a0c Release version 0.2.1 2026-07-16 00:40:31 +00:00
65 changed files with 4601 additions and 1923 deletions
+7
View File
@@ -16,3 +16,10 @@ old-attempt/
# Generated artifacts
*.pyc
*.sqlite
# Local browser-test dependencies, credentials, and generated output
tests/browser/node_modules/
tests/browser/test-results/
tests/browser/playwright-report/
tests/browser/.auth/
tests/browser/jellyfin.local.json
+20 -4
View File
@@ -3,7 +3,20 @@
Multilang is a Jellyfin plugin that lets different users see different metadata
languages without changing Jellyfin's stored metadata.
The plugin is built for Jellyfin 10.11.9 and is currently early beta software.
The `main` branch is built for the Jellyfin 10.11.x stable series. The
`jellyfin12` branch targets Jellyfin 12.0 stable on Linux x64. The plugin remains
beta software.
## Upgrading to 0.2.5 or 0.3.4
Existing plugin data is upgraded automatically. Make fresh database exports after
upgrading: older translation-database exports cannot be imported by these versions.
User-settings-only exports keep their existing format. Obsolete text-based rules
are no longer supported; current classification rules are unaffected.
The first missing-data scan records the new per-language and per-provider fetch
state, so it may fetch more than subsequent scans. Later scans retry only missing
data when due and repair downloaded artwork without refetching its metadata.
## What It Does
@@ -86,9 +99,12 @@ the UI.
Some requests can be slow because Multilang may need to ask Jellyfin for a
larger list than the UI will finally display. This is needed so the plugin can
translate, filter, sort, and slice the list using the user's own rules.
Multilang caches slow transformed responses for a configurable time and also
uses a very short burst cache for repeated requests during navigation. Cache
entries are invalidated when affected items are refreshed.
Multilang caches Jellyfin's responses for a configurable time, sharing the fetched
list across pages and sort orders. Current translations and user rules are applied
each time that list is used. Simultaneous requests share an in-progress fetch, and
a short burst cache also handles fast repeated requests. Library, permission and
watched-state changes invalidate affected caches. Movie and show libraries can
optionally be fetched in advance when a user becomes active.
## Original-Language Support
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "9.0.116",
"version": "10.0.110",
"rollForward": "latestFeature"
}
}
@@ -1,4 +1,6 @@
using System.Reflection;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
@@ -17,6 +19,7 @@ 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;
@@ -26,15 +29,6 @@ namespace Jellyfin.Plugin.Multilang.Api;
[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;
@@ -42,11 +36,14 @@ public sealed class MultilangController : ControllerBase
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 ItemsProxyRequestCoalescer _itemsProxyRequestCoalescer;
private readonly ItemsProxyPrecacheService _itemsProxyPrecacheService;
private readonly ItemsProxyTransformer _itemsProxyTransformer;
private readonly AssetStorageService _assetStorage;
private readonly MultilangBackupService _backupService;
@@ -59,11 +56,14 @@ public sealed class MultilangController : ControllerBase
IUserManager userManager,
IHttpClientFactory httpClientFactory,
IApplicationPaths appPaths,
ILocalizationManager localizationManager,
ProviderCatalog providerCatalog,
SortArticleCatalog articleCatalog,
RefreshService refreshService,
FanartClient fanartClient,
ItemsProxyCache itemsProxyCache,
ItemsProxyRequestCoalescer itemsProxyRequestCoalescer,
ItemsProxyPrecacheService itemsProxyPrecacheService,
ItemsProxyTransformer itemsProxyTransformer,
AssetStorageService assetStorage,
MultilangBackupService backupService)
@@ -75,11 +75,14 @@ public sealed class MultilangController : ControllerBase
_userManager = userManager;
_httpClientFactory = httpClientFactory;
_appPaths = appPaths;
_localizationManager = localizationManager;
_providerCatalog = providerCatalog;
_articleCatalog = articleCatalog;
_refreshService = refreshService;
_fanartClient = fanartClient;
_itemsProxyCache = itemsProxyCache;
_itemsProxyRequestCoalescer = itemsProxyRequestCoalescer;
_itemsProxyPrecacheService = itemsProxyPrecacheService;
_itemsProxyTransformer = itemsProxyTransformer;
_assetStorage = assetStorage;
_backupService = backupService;
@@ -98,10 +101,18 @@ public sealed class MultilangController : ControllerBase
=> 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();
public async Task<IActionResult> Asset(string path)
{
var userId = await GetUserIdAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(new { Error = "NotAuthenticated" });
if (!Guid.TryParse(path.Split('/')[0], out var itemId) || !CanAccessItem(itemId, userId) ||
!_store.IsAssetReferenced(itemId.ToString("N"), TranslationStore.LocalAssetUrlPrefix + path) ||
!_assetStorage.TryResolveLocalAsset(path, out var fullPath, out var contentType))
return NotFound();
Response.Headers.CacheControl = "private, no-cache";
return PhysicalFile(fullPath, contentType);
}
[HttpGet("Providers")]
public IActionResult Providers()
@@ -111,6 +122,13 @@ public sealed class MultilangController : ControllerBase
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()
{
@@ -143,6 +161,8 @@ public sealed class MultilangController : ControllerBase
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)
@@ -195,10 +215,9 @@ public sealed class MultilangController : ControllerBase
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);
UserRulesNormalizer.Normalize(request, allowed);
_store.SaveUserRules(userId, request);
_itemsProxyCache.ClearAll();
_itemsProxyCache.ClearUser(userId);
return Ok(request);
}
@@ -246,12 +265,16 @@ public sealed class MultilangController : ControllerBase
if (!TryParseItemId(itemId, out _, out var itemGuid))
return BadRequest(new { Error = "InvalidItemId" });
await _refreshService.RefreshItemAsync(itemGuid, includeChildren, cancellationToken).ConfigureAwait(false);
var user = _userManager.GetUserById(Guid.Parse(userId));
if (user is null || !CanAccessItem(itemGuid, userId))
return NotFound();
await _refreshService.RefreshItemAsync(itemGuid, includeChildren, cancellationToken, user).ConfigureAwait(false);
return Ok(new { ItemId = itemGuid.ToString("N"), IncludeChildren = includeChildren });
}
[HttpGet("ItemsProxy")]
public async Task<IActionResult> ItemsProxy([FromQuery] string url, CancellationToken cancellationToken)
public async Task<IActionResult> ItemsProxy([FromQuery] string url, [FromQuery] bool precache, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(url))
return BadRequest(new { Error = "MissingUrl" });
@@ -264,8 +287,13 @@ public sealed class MultilangController : ControllerBase
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(new { Error = "NotAuthenticated" });
var user = _userManager.GetUserById(Guid.Parse(userId));
if (user is null || user.HasPermission(PermissionKind.IsDisabled))
return Unauthorized(new { Error = "NotAuthenticated" });
// Jellyfin's UpdatePolicyAsync does not publish a user-update event.
var generation = _itemsProxyCache.ObserveUserPolicy(userId, JsonSerializer.Serialize(_userManager.GetUserDto(user).Policy));
var rules = GetRulesOrDefault(userId);
var proxyRequest = ItemsProxyRequestBuilder.Build(Request, url, token, userId, rules.Enabled);
var proxyRequest = ItemsProxyRequestBuilder.Build(Request, url, userId, rules.Enabled);
if (proxyRequest is null)
return BadRequest(new { Error = "InvalidUrl" });
@@ -273,8 +301,19 @@ public sealed class MultilangController : ControllerBase
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 (!precache && cacheAllowed)
{
_itemsProxyPrecacheService.ObserveUserActivity(
userId,
token,
proxyRequest.Controls.ClientLocale,
cfg.PrecacheMovieLibraries,
cfg.PrecacheTvShowLibraries);
}
if (cacheAllowed && _itemsProxyCache.TryGet(proxyRequest.CacheKey, cacheTtl, out var cached))
{
var transformed = _itemsProxyTransformer.TransformItemsResponse(cached.Body, rules, proxyRequest.Controls);
totalSw.Stop();
_itemsProxyCache.RecordRequest(
proxyRequest.NormalizedUrlForCache,
@@ -284,9 +323,9 @@ public sealed class MultilangController : ControllerBase
itemCount: cached.ItemCount,
totalMs: totalSw.ElapsedMilliseconds,
upstreamMs: 0,
transformMs: 0,
transformMs: totalSw.ElapsedMilliseconds,
sizeBytes: cached.SizeBytes);
return Content(cached.Body, cached.ContentType);
return Content(transformed.Body, cached.ContentType);
}
if (rules.Enabled && proxyRequest.IsGenresRequest && _itemsProxyTransformer.TryBuildGenresResponse(proxyRequest.GenreMedia, proxyRequest.Controls.ClientLocale, out var genresBody))
@@ -307,16 +346,29 @@ public sealed class MultilangController : ControllerBase
}
var upstreamSw = Stopwatch.StartNew();
var http = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream);
var upstream = await _itemsProxyRequestCoalescer.GetOrFetchAsync(
generation + "|" + proxyRequest.NormalizedUrlForCache,
async sharedCancellationToken =>
{
var http = _httpClientFactory.CreateClient("Multilang.Jellyfin");
var localUri = new Uri(_itemsProxyPrecacheService.LocalApiUri, proxyRequest.Upstream.PathAndQuery);
using var request = new HttpRequestMessage(HttpMethod.Get, localUri);
request.Headers.TryAddWithoutValidation("Authorization", $"MediaBrowser Token=\"{token}\"");
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);
using var response = await http.SendAsync(request, sharedCancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(sharedCancellationToken).ConfigureAwait(false);
return new ItemsProxyUpstreamResponse(
(int)response.StatusCode,
body,
response.Content.Headers.ContentType?.ToString() ?? "application/json");
},
cancellationToken).ConfigureAwait(false);
upstreamSw.Stop();
var body = upstream.Response.Body;
string[] responseItemIds = [];
var transformMs = 0L;
if (response.IsSuccessStatusCode && rules.Enabled)
if (upstream.Response.IsSuccessStatusCode && rules.Enabled)
{
var transformSw = Stopwatch.StartNew();
var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls);
@@ -327,11 +379,11 @@ public sealed class MultilangController : ControllerBase
}
totalSw.Stop();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/json";
var contentType = upstream.Response.ContentType;
var bodySizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
var cacheStored = false;
if (cacheAllowed &&
response.IsSuccessStatusCode &&
upstream.Response.IsSuccessStatusCode &&
responseItemIds.Length > 0 &&
totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs))
{
@@ -339,62 +391,57 @@ public sealed class MultilangController : ControllerBase
proxyRequest.CacheKey,
userId,
proxyRequest.NormalizedUrlForCache,
responseItemIds,
body,
responseItemIds.Length,
upstream.Response.Body,
contentType,
totalSw.ElapsedMilliseconds,
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L,
cacheTtl);
cacheTtl,
generation);
}
if (cacheAllowed &&
!cacheStored &&
response.IsSuccessStatusCode &&
upstream.Response.IsSuccessStatusCode &&
responseItemIds.Length > 0)
{
_itemsProxyCache.StoreMicro(
proxyRequest.CacheKey,
userId,
proxyRequest.NormalizedUrlForCache,
responseItemIds,
body,
responseItemIds.Length,
upstream.Response.Body,
contentType,
totalSw.ElapsedMilliseconds,
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L);
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L,
generation,
cacheTtl);
}
_itemsProxyCache.RecordRequest(
proxyRequest.NormalizedUrlForCache,
cacheHit: false,
cacheStored: cacheStored,
statusCode: (int)response.StatusCode,
statusCode: upstream.Response.StatusCode,
itemCount: responseItemIds.Length,
totalMs: totalSw.ElapsedMilliseconds,
upstreamMs: upstreamSw.ElapsedMilliseconds,
transformMs: transformMs,
sizeBytes: bodySizeBytes);
sizeBytes: bodySizeBytes,
inFlightCoalesced: upstream.JoinedExistingRequest);
return new ContentResult
{
StatusCode = (int)response.StatusCode,
StatusCode = upstream.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,
ItemsProxyResolutionAttempt[] Attempts,
ItemsProxyResolvedText Result,
string SelectedAction,
string Reason);
@@ -402,7 +449,7 @@ public sealed class MultilangController : ControllerBase
private sealed record DebugAssetResolution(
string Kind,
string[] Actions,
DebugResolutionAttempt[] Attempts,
ItemsProxyResolutionAttempt[] Attempts,
ItemsProxyResolvedAsset Result,
string SelectedAction,
string Reason);
@@ -473,6 +520,7 @@ public sealed class MultilangController : ControllerBase
if (downloadedAssets && !translationsDatabase)
return BadRequest(new { Error = "AssetsRequireDatabase" });
using var maintenance = await _store.EnterMaintenanceAsync(HttpContext.RequestAborted).ConfigureAwait(false);
var bytes = _backupService.Export(
new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets),
Plugin.Instance?.Configuration ?? new PluginConfiguration());
@@ -502,6 +550,7 @@ public sealed class MultilangController : ControllerBase
try
{
await using var stream = file.OpenReadStream();
using var maintenance = await _store.EnterMaintenanceAsync(HttpContext.RequestAborted).ConfigureAwait(false);
var result = _backupService.ImportAdmin(
stream,
options,
@@ -525,6 +574,7 @@ public sealed class MultilangController : ControllerBase
if (request.Confirm != true)
return BadRequest(new { Error = "ConfirmationRequired" });
using var maintenance = await _store.EnterMaintenanceAsync(HttpContext.RequestAborted).ConfigureAwait(false);
Plugin.Instance?.UpdateConfiguration(new PluginConfiguration());
_store.ResetAll();
_itemsProxyCache.ClearAll();
@@ -583,7 +633,7 @@ public sealed class MultilangController : ControllerBase
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();
_itemsProxyCache.ClearUser(userId);
return Ok(result);
}
catch (InvalidOperationException ex)
@@ -599,9 +649,12 @@ public sealed class MultilangController : ControllerBase
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(new { Error = "NotAuthenticated" });
if (!TryParseItemId(itemId, out var itemId32, out _))
if (!TryParseItemId(itemId, out var itemId32, out var itemGuid))
return BadRequest(new { Error = "InvalidItemId" });
if (!CanAccessItem(itemGuid, userId))
return NotFound();
return Ok(new
{
ItemId = itemId32,
@@ -631,7 +684,7 @@ public sealed class MultilangController : ControllerBase
if (normalizedUserId is null)
return BadRequest(new { Error = "InvalidUserId" });
return DebugResolveForUser(itemId, normalizedUserId);
return DebugResolveForUser(itemId, normalizedUserId, administrator: true);
}
[HttpGet("DebugResolve/{itemId}")]
@@ -644,11 +697,14 @@ public sealed class MultilangController : ControllerBase
return DebugResolveForUser(itemId, userId);
}
private IActionResult DebugResolveForUser(string itemId, string userId)
private IActionResult DebugResolveForUser(string itemId, string userId, bool administrator = false)
{
if (!TryParseItemId(itemId, out var itemId32, out _))
if (!TryParseItemId(itemId, out var itemId32, out var itemGuid))
return BadRequest(new { Error = "InvalidItemId" });
if (!administrator && !CanAccessItem(itemGuid, userId))
return NotFound();
var facts = _store.GetFacts(itemId32);
if (facts is null)
return NotFound(new { Error = "FactsMissing" });
@@ -767,128 +823,27 @@ public sealed class MultilangController : ControllerBase
}
private static DebugTextResolution TraceResolveField(
string field,
IReadOnlyList<string> actions,
FactsData facts,
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);
var attempts = new List<ItemsProxyResolutionAttempt>();
var result = ItemsProxyTransformer.ResolveField(field, actions, facts, byLang, attempts.Add);
var chosen = attempts.FirstOrDefault(attempt => attempt.Chosen);
return new(field, actions.ToArray(), attempts.ToArray(), result,
chosen?.Action ?? (result.Change ? ClearAction : JellyfinAction),
chosen?.Reason ?? (result.Change ? "No action had data; field is cleared" : "No action had data; use Jellyfin"));
}
private static DebugAssetResolution TraceResolveAsset(
string kind,
IReadOnlyList<string> actions,
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);
var attempts = new List<ItemsProxyResolutionAttempt>();
var result = ItemsProxyTransformer.ResolveAsset(kind, actions, byKind, attempts.Add);
var chosen = attempts.FirstOrDefault(attempt => attempt.Chosen);
return new(kind, actions.ToArray(), attempts.ToArray(), result,
chosen?.Action ?? (result.Change ? ClearAction : JellyfinAction),
chosen?.Reason ?? (result.Change ? "No action had data; image is cleared" : "No action had data; use Jellyfin"));
}
private static object[] BuildMissingDataReport(
@@ -983,238 +938,7 @@ public sealed class MultilangController : ControllerBase
}
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;
}
=> _store.GetUserRules(userId) ?? new UserRulesDocument { Enabled = false };
private IActionResult ServeEmbedded(string resource, string contentType)
{
@@ -1250,10 +974,6 @@ public sealed class MultilangController : ControllerBase
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);
@@ -1274,54 +994,18 @@ public sealed class MultilangController : ControllerBase
: 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;
return Guid.TryParse(userId, out var guid) &&
_userManager.GetUserById(guid)?.HasPermission(PermissionKind.IsAdministrator) == true;
}
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 bool CanAccessItem(Guid itemId, string userId)
{
var user = _userManager.GetUserById(Guid.Parse(userId));
return user is not null && !user.HasPermission(PermissionKind.IsDisabled) &&
_libraryManager.GetItemById(itemId)?.IsVisibleStandalone(user) == true;
}
private static bool FormBool(IFormCollection form, string key, bool fallback)
@@ -1334,28 +1018,6 @@ public sealed class MultilangController : ControllerBase
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;
@@ -1430,8 +1092,16 @@ public sealed class AdminConfigDto
public int ItemsProxyCacheMaxMiB { get; set; }
public bool PrecacheMovieLibraries { get; set; } = true;
public bool PrecacheTvShowLibraries { get; set; } = true;
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; }
@@ -1452,7 +1122,11 @@ public sealed class AdminConfigDto
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
PrecacheMovieLibraries = cfg.PrecacheMovieLibraries,
PrecacheTvShowLibraries = cfg.PrecacheTvShowLibraries,
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback,
ArticleEntries = cfg.ArticleEntries,
EnableLogging = cfg.EnableLogging,
VerboseLogging = cfg.VerboseLogging,
@@ -1474,7 +1148,11 @@ public sealed class AdminConfigDto
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
PrecacheMovieLibraries = PrecacheMovieLibraries,
PrecacheTvShowLibraries = PrecacheTvShowLibraries,
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 })
@@ -27,8 +27,16 @@ public sealed class PluginConfiguration : BasePluginConfiguration
public int ItemsProxyCacheMaxMiB { get; set; } = 50;
public bool PrecacheMovieLibraries { get; set; } = true;
public bool PrecacheTvShowLibraries { get; set; } = true;
public string AssetStorageMode { get; set; } = "url";
public bool IgnoreArticlesWhenSorting { get; set; } = true;
public string JellyfinTitleLanguageFallback { get; set; } = string.Empty;
[XmlArray("SortArticles")]
[XmlArrayItem("SortArticleEntry")]
public SortArticleEntry[] ArticleEntries { get; set; } = [];
@@ -28,16 +28,25 @@
</fieldset>
<fieldset class="ml-section">
<legend>Languages</legend>
<legend>Languages to fetch</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-languages">Languages</label>
<label class="inputLabel inputLabelUnfocused" for="ml-languages">Languages to fetch</label>
<input id="ml-languages" type="text" class="emby-input ml-input" placeholder="en-US, fi-FI, sv-SE">
<div class="fieldDescription">Use BCP-47 language tags. These are the languages refresh jobs fetch and user rules can select.</div>
</div>
<div class="inputContainer">
<label><input id="ml-ignore-articles" type="checkbox"> Ignore articles when sorting</label>
</div>
<div id="ml-jellyfin-title-language-row" class="inputContainer ml-article-sorting-fallback">
<label class="inputLabel inputLabelUnfocused" for="ml-jellyfin-title-language">When in doubt, assume title language is</label>
<select id="ml-jellyfin-title-language" is="emby-select" class="emby-select-withcolor emby-select ml-input"></select>
</div>
<details id="ml-article-details" class="ml-articles">
<summary>Articles</summary>
<summary>List of articles</summary>
<div class="fieldDescription">Comma- or semicolon-separated leading articles to ignore per language.</div>
<div class="fieldDescription">To ignore a language's articles, even on titles in other languages, check "Always ignore".</div>
<div id="ml-article-list" class="ml-articles-list"></div>
<div class="ml-articles-actions">
<button id="ml-article-add" is="emby-button" type="button" class="raised"><span>Add language</span></button>
@@ -69,17 +78,28 @@
<fieldset class="ml-section">
<legend>Refresh activity</legend>
<div class="fieldDescription">Live diagnostics are kept in memory and reset when Jellyfin restarts. While a scan runs, the scan details update live. Otherwise they describe the most recently completed scan in this Jellyfin session.</div>
<button id="ml-refresh-activity-refresh" is="emby-button" type="button" class="raised"><span>Refresh activity</span></button>
<h3 class="ml-subheading">Current scan</h3>
<details class="ml-diagnostics" open>
<summary id="ml-refresh-scan-summary">Latest scan</summary>
<div class="fieldDescription">A completed scan is idle because no scan is running now. Its result and counters remain here until another scan starts.</div>
<div id="ml-refresh-scan" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Queue</h3>
</details>
<details class="ml-diagnostics">
<summary id="ml-refresh-queue-summary">Queue</summary>
<div class="fieldDescription">Pending entries have not started. Recently completed entries are history only and are no longer queued.</div>
<div id="ml-refresh-queue" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Provider HTTP</h3>
</details>
<details class="ml-diagnostics">
<summary id="ml-provider-http-summary">Provider HTTP</summary>
<div class="fieldDescription">Counts are final provider outcomes since this Jellyfin session began. A request may retry up to three times; “attempt 1” means it completed without a retry.</div>
<div id="ml-provider-http" class="ml-cache-entries"></div>
</details>
</fieldset>
<fieldset class="ml-section">
<legend>Items cache</legend>
<div class="fieldDescription">This is an in-memory cache of slow ItemsProxy responses. Entries expire after the configured TTL, are invalidated when their items change, and disappear when Jellyfin restarts.</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-cache-threshold">ItemsProxy cache threshold ms</label>
<input id="ml-cache-threshold" type="number" class="emby-input ml-input" min="0" step="100">
@@ -93,14 +113,25 @@
<label class="inputLabel inputLabelUnfocused" for="ml-cache-max">ItemsProxy cache max MiB</label>
<input id="ml-cache-max" type="number" class="emby-input ml-input" min="1" step="1">
</div>
<div class="inputContainer">
<label><input id="ml-precache-movies" type="checkbox"> Pre-cache movie library pages for returning users</label>
<label><input id="ml-precache-tvshows" type="checkbox"> Pre-cache TV show library pages for returning users</label>
<div class="fieldDescription">The first proxied request after 30 minutes away starts this in the background. Only libraries the user can see are included. Uncheck both to disable pre-caching.</div>
</div>
<div class="ml-actions">
<button id="ml-cache-refresh" is="emby-button" type="button" class="raised"><span>Refresh cache diagnostics</span></button>
<button id="ml-cache-clear" is="emby-button" type="button" class="raised ml-danger"><span>Clear cache</span></button>
</div>
<h3 class="ml-subheading">Stored responses</h3>
<details class="ml-diagnostics" open>
<summary id="ml-cache-entries-summary">Cached responses</summary>
<div class="fieldDescription">These complete responses were cached because they met the configured threshold. Their ages update while this page is open.</div>
<div id="ml-cache-entries" class="ml-cache-entries"></div>
<h3 class="ml-subheading">Recent requests</h3>
</details>
<details class="ml-diagnostics">
<summary id="ml-proxy-requests-summary">Recent proxy requests</summary>
<div class="fieldDescription">The 100 most recent proxy requests, kept for this Jellyfin session. “In-flight hit” waited for a matching upstream request already in progress. “Cache miss: cached” means a cache miss that was added to the full cache; “cache miss” means the response was returned without being retained.</div>
<div id="ml-proxy-requests" class="ml-cache-entries"></div>
</details>
</fieldset>
<fieldset class="ml-section">
@@ -126,6 +157,22 @@
<div id="ml-storage-info" class="fieldDescription"></div>
</fieldset>
<fieldset class="ml-section">
<legend>Debug</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-debug-item-id">Jellyfin item ID</label>
<input id="ml-debug-item-id" type="text" class="emby-input ml-input" placeholder="32-character item ID">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-debug-user-id">User ID</label>
<input id="ml-debug-user-id" type="text" class="emby-input ml-input" placeholder="Optional; leave blank for your own settings">
</div>
<div class="ml-actions">
<button id="ml-debug-open" is="emby-button" type="button" class="raised"><span>Open debug view</span></button>
</div>
<div class="fieldDescription">The selected users classification and translation rules are shown. Viewing another user requires administrator access.</div>
</fieldset>
<button id="ml-save" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form>
@@ -179,7 +226,7 @@
<script>
(function () {
var M = window.Multilang;
var state = { config: null, providers: [], articles: {} };
var state = { config: null, providers: [], articles: {}, metadataLanguages: [] };
var page = document.getElementById("ml-admin");
var form = document.getElementById("ml-admin-form");
var status = document.getElementById("ml-status");
@@ -226,6 +273,38 @@
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
}
function renderJellyfinTitleLanguages() {
var select = document.getElementById("ml-jellyfin-title-language");
var selected = M.prop(state.config, "JellyfinTitleLanguageFallback", "jellyfinTitleLanguageFallback", "");
M.replaceChildren(select);
var defaultOption = make("option", "", "Jellyfin's default");
defaultOption.value = "";
select.appendChild(defaultOption);
var separator = make("option", "", "--------------------");
separator.disabled = true;
select.appendChild(separator);
state.metadataLanguages.forEach(function (language) {
var name = String(M.prop(language, "Name", "name", ""));
if (!name) return;
var option = make("option", "", String(M.prop(language, "DisplayName", "displayName", name)) + " (" + name + ")");
option.value = name;
select.appendChild(option);
});
if (selected && !Array.prototype.some.call(select.options, function (option) { return option.value === selected; })) {
var unknown = make("option", "", selected);
unknown.value = selected;
select.appendChild(unknown);
}
select.value = selected;
}
function syncArticleSortingControls() {
var enabled = document.getElementById("ml-ignore-articles").checked;
var row = document.getElementById("ml-jellyfin-title-language-row");
row.classList.toggle("ml-disabled", !enabled);
document.getElementById("ml-jellyfin-title-language").disabled = !enabled;
}
function syncProviderSourceWidth() {
var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source"));
if (!sources.length) return;
@@ -238,7 +317,7 @@
}
function renderProviderInputs() {
providerInputs.replaceChildren();
M.replaceChildren(providerInputs);
state.providers.forEach(function (provider) {
var id = providerId(provider);
var keyField = providerKeyField(provider);
@@ -292,7 +371,7 @@
.map(providerId)
.filter(function (id) { return providerSupports(providerById(id), bucket) && orderFor(id, bucket) >= 0; })
.sort(function (a, b) { return orderFor(a, bucket) - orderFor(b, bucket); });
list.replaceChildren();
M.replaceChildren(list);
ids.forEach(function (id) { list.appendChild(makeProviderChip(id)); });
}
@@ -316,7 +395,7 @@
var alwaysInput = make("input", "ml-articles-apply-toggle");
alwaysInput.type = "checkbox";
alwaysInput.checked = !!alwaysApply;
always.append(alwaysInput, make("span", "", "Always"));
always.append(alwaysInput, make("span", "", "Always ignore"));
var remove = make("button", "raised ml-articles-remove");
remove.type = "button";
remove.setAttribute("is", "emby-button");
@@ -333,7 +412,7 @@
}
function renderArticles() {
articleList.replaceChildren();
M.replaceChildren(articleList);
var configured = new Map();
(state.config.ArticleEntries || []).forEach(function (entry) {
var lang = normalizeLang(entry.Language || entry.language);
@@ -382,7 +461,11 @@
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0);
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1);
state.config.PrecacheMovieLibraries = document.getElementById("ml-precache-movies").checked;
state.config.PrecacheTvShowLibraries = document.getElementById("ml-precache-tvshows").checked;
state.config.AssetStorageMode = nextStorageMode;
state.config.IgnoreArticlesWhenSorting = document.getElementById("ml-ignore-articles").checked;
state.config.JellyfinTitleLanguageFallback = document.getElementById("ml-jellyfin-title-language").value;
state.config.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked;
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
state.config.CleanupDataOnUninstall = document.getElementById("ml-cleanup-uninstall").checked;
@@ -404,39 +487,81 @@
document.getElementById("ml-cache-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000;
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50;
document.getElementById("ml-precache-movies").checked = state.config.PrecacheMovieLibraries !== false && state.config.precacheMovieLibraries !== false;
document.getElementById("ml-precache-tvshows").checked = state.config.PrecacheTvShowLibraries !== false && state.config.precacheTvShowLibraries !== false;
document.getElementById("ml-asset-storage").value = (state.config.AssetStorageMode || state.config.assetStorageMode || "url").toLowerCase() === "local" ? "local" : "url";
document.getElementById("ml-ignore-articles").checked = state.config.IgnoreArticlesWhenSorting !== false && state.config.ignoreArticlesWhenSorting !== false;
document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging;
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
renderProviderInputs();
renderProviderBuckets();
renderJellyfinTitleLanguages();
syncArticleSortingControls();
renderArticles();
loadCacheDiagnostics();
loadRefreshDiagnostics();
loadExportInfo();
}
function setDiagnosticsSummary(id, text) {
document.getElementById(id).textContent = text;
}
function formatAge(seconds) {
seconds = Math.max(0, Math.floor(Number(seconds) || 0));
if (seconds < 60) return seconds + " s old";
var minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + " min " + (seconds % 60) + " s old";
var hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " h " + (minutes % 60) + " min old";
return Math.floor(hours / 24) + " d " + (hours % 24) + " h old";
}
function makeAgeChip(seconds) {
var chip = make("span", "ml-diagnostic-chip ml-age", "");
chip.dataset.ageSeconds = String(Math.max(0, Number(seconds) || 0));
chip.dataset.ageRenderedAt = String(Date.now());
updateAgeChip(chip);
return chip;
}
function updateAgeChip(chip) {
var initialAge = Number(chip.dataset.ageSeconds || 0);
var renderedAt = Number(chip.dataset.ageRenderedAt || Date.now());
chip.textContent = formatAge(initialAge + (Date.now() - renderedAt) / 1000);
}
function ensureAgeTicker() {
if (page._mlAgeTicker) return;
page._mlAgeTicker = window.setInterval(function () {
Array.prototype.forEach.call(page.querySelectorAll(".ml-age"), updateAgeChip);
}, 1000);
}
function renderCacheEntries(entries, error) {
var root = document.getElementById("ml-cache-entries");
root.replaceChildren();
M.replaceChildren(root);
if (error) {
setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses");
root.appendChild(make("div", "ml-error", error));
return;
}
setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses (" + (entries ? entries.length : 0) + ")");
if (!entries || entries.length === 0) {
root.appendChild(make("div", "ml-muted", "No cache entries."));
return;
}
entries.forEach(function (entry) {
var url = entry.Url || entry.url || "";
var url = M.prop(entry, "Url", "url", "");
root.appendChild(makeDiagnosticRow(url, [
"cached",
(entry.ItemCount || entry.itemCount || 0) + " items",
(entry.DurationMs || entry.durationMs || 0) + " ms",
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB",
(entry.AgeSeconds || entry.ageSeconds || 0) + " s old"
M.prop(entry, "ItemCount", "itemCount", 0) + " items",
M.prop(entry, "DurationMs", "durationMs", 0) + " ms",
(M.prop(entry, "SizeBytes", "sizeBytes", 0) / 1024).toFixed(1) + " KiB",
makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0))
]));
});
}
@@ -451,28 +576,35 @@
function renderProxyRequests(entries, error) {
var root = document.getElementById("ml-proxy-requests");
root.replaceChildren();
M.replaceChildren(root);
if (error) {
setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests");
root.appendChild(make("div", "ml-error", error));
return;
}
setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests (" + (entries ? entries.length : 0) + " of last 100)");
if (!entries || entries.length === 0) {
root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests."));
return;
}
entries.forEach(function (entry) {
var url = entry.Url || entry.url || "";
var url = M.prop(entry, "Url", "url", "");
var cacheState = M.prop(entry, "CacheHit", "cacheHit", false)
? "cache hit"
: (M.prop(entry, "InFlightCoalesced", "inFlightCoalesced", false)
? "in-flight hit"
: (M.prop(entry, "CacheStored", "cacheStored", false) ? "cache miss: cached" : "cache miss"));
root.appendChild(makeDiagnosticRow(url, [
(entry.CacheHit || entry.cacheHit) ? "hit" : ((entry.CacheStored || entry.cacheStored) ? "stored" : "miss"),
"HTTP " + (entry.StatusCode || entry.statusCode || 0),
(entry.ItemCount || entry.itemCount || 0) + " items",
"total " + (entry.TotalMs || entry.totalMs || 0) + " ms",
"upstream " + (entry.UpstreamMs || entry.upstreamMs || 0) + " ms",
"transform " + (entry.TransformMs || entry.transformMs || 0) + " ms",
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB",
(entry.AgeSeconds || entry.ageSeconds || 0) + " s old"
cacheState,
"HTTP " + M.prop(entry, "StatusCode", "statusCode", 0),
M.prop(entry, "ItemCount", "itemCount", 0) + " items",
"total " + M.prop(entry, "TotalMs", "totalMs", 0) + " ms",
"upstream " + M.prop(entry, "UpstreamMs", "upstreamMs", 0) + " ms",
"transform " + M.prop(entry, "TransformMs", "transformMs", 0) + " ms",
(M.prop(entry, "SizeBytes", "sizeBytes", 0) / 1024).toFixed(1) + " KiB",
makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0))
]));
});
}
@@ -481,7 +613,9 @@
var row = make("div", "ml-diagnostic-row");
var top = make("div", "ml-diagnostic-meta");
parts.forEach(function (part, index) {
var chip = make("span", index === 0 ? "ml-diagnostic-chip ml-diagnostic-chip-strong" : "ml-diagnostic-chip", part);
var chip = part && part.nodeType === 1
? part
: make("span", index === 0 ? "ml-diagnostic-chip ml-diagnostic-chip-strong" : "ml-diagnostic-chip", part);
top.appendChild(chip);
});
var urlEl = make("div", "ml-cache-url", url);
@@ -499,6 +633,7 @@
}
function loadCacheDiagnostics() {
ensureAgeTicker();
loadCacheEntries();
loadProxyRequests();
}
@@ -523,15 +658,28 @@
return new Date(value * 1000).toLocaleString();
}
function scanMode(value) {
return String(value || "").toLowerCase() === "missing" ? "missing data" : String(value || "full data").toLowerCase();
}
function scanSource(value) {
return String(value || "").toLowerCase() === "scheduled" ? "scheduled task" : String(value || "manual").toLowerCase();
}
function diagnosticHeading(text) {
return make("div", "ml-diagnostic-heading", text);
}
function renderRefreshDiagnostics(data, error) {
var scanRoot = document.getElementById("ml-refresh-scan");
var queueRoot = document.getElementById("ml-refresh-queue");
var httpRoot = document.getElementById("ml-provider-http");
scanRoot.replaceChildren();
queueRoot.replaceChildren();
httpRoot.replaceChildren();
M.replaceChildren(scanRoot);
M.replaceChildren(queueRoot);
M.replaceChildren(httpRoot);
if (error) {
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan");
scanRoot.appendChild(make("div", "ml-error", error));
return;
}
@@ -541,53 +689,79 @@
var queue = prop(data, "Queue", "queue", {});
var providerHttp = prop(data, "ProviderHttp", "providerHttp", {});
var running = !!prop(scan, "Running", "running", false);
scanRoot.appendChild(makeDiagnosticRow("Last scan started: " + formatUnixTime(prop(data, "LastScanStarted", "lastScanStarted", 0)), [
running ? "running" : "idle",
String(prop(scan, "Mode", "mode", "") || "none"),
String(prop(scan, "Source", "source", "") || "none"),
"items " + Number(prop(scan, "Items", "items", 0) || 0),
"due " + Number(prop(scan, "Due", "due", 0) || 0),
"refreshed " + Number(prop(scan, "Refreshed", "refreshed", 0) || 0),
"skipped " + (Number(prop(scan, "SkippedNotDue", "skippedNotDue", 0) || 0) + Number(prop(scan, "SkippedNoData", "skippedNoData", 0) || 0)),
"new " + Number(prop(scan, "New", "new", 0) || 0),
"deleted " + Number(prop(scan, "Deleted", "deleted", 0) || 0),
"current " + Number(prop(scan, "CurrentIndex", "currentIndex", 0) || 0) + "/" + Number(prop(scan, "Items", "items", 0) || 0),
String(prop(scan, "CurrentAction", "currentAction", "") || "idle"),
Number(prop(scan, "ElapsedMs", "elapsedMs", 0) || 0) + " ms"
var scanStartedAt = Number(prop(scan, "StartedAt", "startedAt", 0) || 0);
var scanSuccess = !!prop(scan, "Success", "success", false);
if (!scanStartedAt) {
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan");
scanRoot.appendChild(make("div", "ml-muted", "No scan diagnostics are available since Jellyfin last started."));
} else {
var result = running ? "running" : (scanSuccess ? "completed" : "failed");
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan (" + result + ")");
scanRoot.appendChild(makeDiagnosticRow("Started: " + formatUnixTime(scanStartedAt), [
running ? "running now" : "not running",
"result: " + result,
"mode: " + scanMode(prop(scan, "Mode", "mode", "")),
"source: " + scanSource(prop(scan, "Source", "source", "")),
"finished: " + formatUnixTime(prop(scan, "FinishedAt", "finishedAt", 0)),
"duration: " + Number(prop(scan, "ElapsedMs", "elapsedMs", 0) || 0) + " ms"
]));
scanRoot.appendChild(makeDiagnosticRow("Scan counters", [
"items checked: " + Number(prop(scan, "Items", "items", 0) || 0),
"eligible: " + Number(prop(scan, "Due", "due", 0) || 0),
"fetched: " + Number(prop(scan, "Refreshed", "refreshed", 0) || 0),
"not due: " + Number(prop(scan, "SkippedNotDue", "skippedNotDue", 0) || 0),
"no provider data: " + Number(prop(scan, "SkippedNoData", "skippedNoData", 0) || 0),
"new: " + Number(prop(scan, "New", "new", 0) || 0),
"removed: " + Number(prop(scan, "Deleted", "deleted", 0) || 0),
"progress: " + Number(prop(scan, "CurrentIndex", "currentIndex", 0) || 0) + "/" + Number(prop(scan, "Items", "items", 0) || 0)
]));
}
var currentItem = String(prop(scan, "CurrentItemId", "currentItemId", "") || "");
if (currentItem) {
scanRoot.appendChild(makeDiagnosticRow("Current item: " + currentItem, [
String(prop(scan, "CurrentName", "currentName", "") || ""),
String(prop(scan, "CurrentKind", "currentKind", "") || "")
scanRoot.appendChild(makeDiagnosticRow(String(prop(scan, "CurrentName", "currentName", "") || currentItem), [
running ? "current item" : "last item processed",
"kind: " + String(prop(scan, "CurrentKind", "currentKind", "") || "unknown"),
"action: " + String(prop(scan, "CurrentAction", "currentAction", "") || "unknown"),
"ID: " + currentItem
]));
}
var scanError = String(prop(scan, "Error", "error", "") || "");
if (scanError) scanRoot.appendChild(make("div", "ml-error", scanError));
var active = prop(queue, "Active", "active", null);
var activeItems = queue.ActiveItems || [];
var queued = prop(queue, "Queued", "queued", []) || [];
var recent = prop(queue, "Recent", "recent", []) || [];
if (active) {
setDiagnosticsSummary("ml-refresh-queue-summary", "Queue (" + Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0) + " pending)");
activeItems.forEach(function (active) {
queueRoot.appendChild(makeDiagnosticRow("Active item: " + String(prop(active, "ItemId", "itemId", "")), [
"active",
String(prop(active, "SourceTier", "sourceTier", "")),
String(prop(active, "WorkClass", "workClass", "")),
String(prop(active, "JobType", "jobType", ""))
"running now",
"source: " + String(prop(active, "SourceTier", "sourceTier", "")),
"type: " + String(prop(active, "WorkClass", "workClass", "")),
"refresh: " + String(prop(active, "JobType", "jobType", ""))
]));
});
if (queued.length === 0) {
queueRoot.appendChild(make("div", "ml-muted", "No pending refresh items."));
} else {
queueRoot.appendChild(diagnosticHeading("Pending refresh items"));
queued.slice(0, 10).forEach(function (item) {
queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [
"pending",
"source: " + String(prop(item, "SourceTier", "sourceTier", "")),
"type: " + String(prop(item, "WorkClass", "workClass", "")),
"refresh: " + String(prop(item, "JobType", "jobType", ""))
]));
});
}
queueRoot.appendChild(makeDiagnosticRow("Queued refresh items", [
"queued",
String(Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0))
]));
if (recent.length > 0) queueRoot.appendChild(diagnosticHeading("Recently completed refreshes (not pending)"));
recent.slice(0, 10).forEach(function (item) {
queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [
prop(item, "Ok", "ok", false) ? "ok" : "failed",
String(prop(item, "SourceTier", "sourceTier", "")),
String(prop(item, "WorkClass", "workClass", "")),
String(prop(item, "JobType", "jobType", "")),
Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms",
formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0))
prop(item, "Ok", "ok", false) ? "completed" : "failed",
"source: " + String(prop(item, "SourceTier", "sourceTier", "")),
"type: " + String(prop(item, "WorkClass", "workClass", "")),
"refresh: " + String(prop(item, "JobType", "jobType", "")),
"duration: " + Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms",
"finished: " + formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0))
]));
var itemError = String(prop(item, "Error", "error", "") || "");
if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError));
@@ -595,23 +769,26 @@
var counts = prop(providerHttp, "Counts", "counts", []) || [];
var providerRecent = prop(providerHttp, "Recent", "recent", []) || [];
setDiagnosticsSummary("ml-provider-http-summary", "Provider HTTP (" + counts.length + " outcome totals, " + providerRecent.length + " recent)");
if (counts.length === 0 && providerRecent.length === 0) {
httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup."));
return;
}
if (counts.length > 0) httpRoot.appendChild(diagnosticHeading("Outcome totals since plugin startup"));
counts.forEach(function (count) {
httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [
"count",
"final outcome",
String(prop(count, "Outcome", "outcome", "")),
String(Number(prop(count, "Count", "count", 0) || 0))
"count: " + String(Number(prop(count, "Count", "count", 0) || 0))
]));
});
if (providerRecent.length > 0) httpRoot.appendChild(diagnosticHeading("Most recent provider requests"));
providerRecent.slice(0, 10).forEach(function (entry) {
httpRoot.appendChild(makeDiagnosticRow(String(prop(entry, "Url", "url", "")), [
String(prop(entry, "Provider", "provider", "")),
String(prop(entry, "Outcome", "outcome", "")),
"attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0),
formatUnixTime(prop(entry, "At", "at", 0))
"completed on attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0),
"at " + formatUnixTime(prop(entry, "At", "at", 0))
]));
});
}
@@ -716,11 +893,13 @@
var all = await Promise.all([
M.get("/Multilang/Providers"),
M.get("/Multilang/ArticleBuiltins"),
M.get("/Multilang/MetadataLanguages"),
M.get("/Multilang/AdminConfig")
]);
state.providers = all[0] || [];
state.articles = all[1] || {};
state.config = all[2] || {};
state.metadataLanguages = all[2] || [];
state.config = all[3] || {};
loadIntoForm();
M.setStatus(status, "");
} catch (err) {
@@ -867,6 +1046,14 @@
document.getElementById("ml-cache-refresh").addEventListener("click", loadCacheDiagnostics);
document.getElementById("ml-cache-clear").addEventListener("click", clearCacheEntries);
document.getElementById("ml-refresh-activity-refresh").addEventListener("click", loadRefreshDiagnostics);
document.getElementById("ml-debug-open").addEventListener("click", function () {
var itemId = document.getElementById("ml-debug-item-id").value.trim();
if (!itemId) {
M.setStatus(status, "Enter a Jellyfin item ID.", true);
return;
}
window.open(M.debugUrl(itemId, document.getElementById("ml-debug-user-id").value.trim()), "_blank", "noopener");
});
document.getElementById("ml-export-open").addEventListener("click", function () { loadExportInfo(); syncAssetCheckboxes(); M.showModal("ml-export-modal"); });
document.getElementById("ml-import-open").addEventListener("click", function () { loadExportInfo(); syncAssetCheckboxes(); M.showModal("ml-import-modal"); });
document.getElementById("ml-cleanup-open").addEventListener("click", function () { document.getElementById("ml-cleanup-confirm").checked = false; M.showModal("ml-cleanup-modal"); });
@@ -884,6 +1071,7 @@
document.getElementById("ml-log-verbose").addEventListener("change", function () {
if (this.checked) document.getElementById("ml-log-enabled").checked = true;
});
document.getElementById("ml-ignore-articles").addEventListener("change", syncArticleSortingControls);
document.getElementById("ml-article-add").addEventListener("click", function () { addArticleRow("", "", false, false); });
document.getElementById("ml-article-repair").addEventListener("click", renderArticles);
document.getElementById("ml-article-reset").addEventListener("click", function () {
@@ -9,7 +9,7 @@
<label class="ml-inline-check">
<span>View:</span>
<select id="ml-debug-mode" class="emby-select ml-select">
<option value="resolved">Resolved for current user</option>
<option value="resolved">Resolved for selected user</option>
<option value="stored">Stored facts and translations</option>
</select>
</label>
@@ -44,12 +44,17 @@
return match ? decodeURIComponent(match[1]) : "";
}
function targetUserId() {
return new URLSearchParams(location.search).get("userId") || "";
}
function endpoint() {
var id = encodeURIComponent(itemId());
var locale = document.documentElement.lang || navigator.language || "";
var suffix = locale ? "?mlLocale=" + encodeURIComponent(locale) : "";
return mode.value === "stored"
? "/Multilang/Debug/" + id
if (mode.value === "stored") return "/Multilang/Debug/" + id;
return targetUserId()
? "/Multilang/Debug/" + id + "/" + encodeURIComponent(targetUserId()) + suffix
: "/Multilang/Debug/" + id + "/self" + suffix;
}
@@ -180,7 +185,7 @@
}
function renderSummary(data) {
summary.replaceChildren();
M.replaceChildren(summary);
if (mode.value !== "resolved") return;
var top = make("div", "ml-diagnostic-meta");
top.append(
@@ -193,7 +198,7 @@
async function load() {
M.setStatus(status, "Loading...");
meta.textContent = "Item: " + itemId() + " | " + mode.options[mode.selectedIndex].text;
meta.textContent = "Item: " + itemId() + " | " + mode.options[mode.selectedIndex].text + (targetUserId() ? " | User: " + targetUserId() : "");
try {
var data = await M.get(endpoint());
lastJson = JSON.stringify(data, null, 2);
@@ -202,7 +207,7 @@
M.setStatus(status, "Loaded.");
} catch (err) {
lastJson = "";
summary.replaceChildren();
M.replaceChildren(summary);
output.textContent = authHint(err);
M.setStatus(status, err.message, true);
}
@@ -297,6 +297,10 @@ select.emby-select option:checked {
opacity: .7;
}
.ml-disabled {
opacity: .5;
}
.ml-error {
color: #ff9b9b;
}
@@ -760,6 +764,27 @@ select.emby-select option:checked {
font-weight: 600;
}
.ml-diagnostics {
margin-top: 1rem;
}
.ml-diagnostics > summary {
cursor: pointer;
font-size: 1rem;
font-weight: 600;
}
.ml-diagnostics > .fieldDescription {
margin-top: .45rem;
}
.ml-diagnostic-heading {
margin-top: .35rem;
color: rgba(255, 255, 255, .72);
font-size: .9rem;
font-weight: 600;
}
.ml-diagnostic-row {
display: flex;
flex-direction: column;
@@ -14,33 +14,32 @@
}
}
function headers() {
const qs = new URLSearchParams(location.search);
const t = token() || qs.get("token") || qs.get("api_key") || "";
const h = { "Content-Type": "application/json" };
if (t) {
h["X-Emby-Token"] = t;
h["X-MediaBrowser-Token"] = t;
}
return h;
}
function authHeaders(contentType) {
const qs = new URLSearchParams(location.search);
const t = token() || qs.get("token") || qs.get("api_key") || "";
const h = {};
if (contentType) h["Content-Type"] = contentType;
if (t) {
h.Authorization = `MediaBrowser Token="${t}"`;
h["X-Emby-Token"] = t;
h["X-MediaBrowser-Token"] = t;
}
return h;
}
function debugUrl(itemId, userId) {
const query = new URLSearchParams();
const t = token() || new URLSearchParams(location.search).get("token") || new URLSearchParams(location.search).get("api_key") || "";
if (t) query.set("token", t);
if (userId) query.set("userId", userId);
const suffix = query.toString();
return `${basePath()}/Multilang/DebugUi/${encodeURIComponent(String(itemId || "").trim())}${suffix ? `?${suffix}` : ""}`;
}
async function request(path, options) {
const response = await fetch(`${basePath()}${path}`, {
cache: "no-store",
headers: headers(),
headers: authHeaders("application/json"),
...options
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -95,41 +94,9 @@
URL.revokeObjectURL(url);
}
function chip(label) {
const el = document.createElement("span");
el.className = "ml-chip";
el.textContent = label;
el.draggable = true;
el.dataset.value = label;
el.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", label);
event.dataTransfer.effectAllowed = "move";
el.classList.add("ml-dragging");
});
el.addEventListener("dragend", () => el.classList.remove("ml-dragging"));
return el;
}
function bucketValues(bucket) {
return [...bucket.querySelectorAll(".ml-chip")].map((el) => el.dataset.value || el.textContent.trim()).filter(Boolean);
}
function renderBucket(bucket, values) {
bucket.replaceChildren(...values.map(chip));
}
function wireBucket(bucket, onDrop) {
bucket.addEventListener("dragover", (event) => {
event.preventDefault();
bucket.classList.add("ml-over");
});
bucket.addEventListener("dragleave", () => bucket.classList.remove("ml-over"));
bucket.addEventListener("drop", (event) => {
event.preventDefault();
bucket.classList.remove("ml-over");
const value = event.dataTransfer.getData("text/plain");
if (value) onDrop(value, bucket);
});
function replaceChildren(parent, ...children) {
parent.textContent = "";
parent.append(...children);
}
const staticData = {
@@ -142,6 +109,7 @@
get: (path) => request(path),
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
basePath,
debugUrl,
authHeaders,
splitList,
setStatus,
@@ -150,10 +118,7 @@
showModal,
hideModals,
downloadBlob,
chip,
bucketValues,
renderBucket,
wireBucket,
replaceChildren,
staticData
};
})();
@@ -39,6 +39,18 @@
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Debug</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-debug-item-id">Jellyfin item ID</label>
<input id="ml-debug-item-id" type="text" class="emby-input ml-input" placeholder="32-character item ID">
</div>
<div class="ml-actions">
<button id="ml-debug-open" is="emby-button" type="button" class="raised"><span>Open debug view</span></button>
</div>
<div class="fieldDescription">Shows how your classification and translation rules resolve for this item.</div>
</fieldset>
<button id="ml-save-user" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form>
@@ -97,8 +109,19 @@
var ISO3166 = M.staticData.iso3166;
var LANG_REGION_COMBOS = M.staticData.langRegionCombos;
var listeners = [];
function listen(target, type, handler, capture) {
target.addEventListener(type, handler, capture);
listeners.push(() => target.removeEventListener(type, handler, capture));
}
page._mlDispose = function () {
listeners.forEach(remove => remove());
listeners = [];
cleanupActionDrag();
};
function $(id) {
return document.getElementById(id);
return page.querySelector("#" + id);
}
function make(tag, className, text) {
@@ -132,18 +155,18 @@
}
function categoryId(category) {
return category.Id || category.id || "";
return category.Id;
}
function categoryLabel(category, index) {
var label = String(category.Label || category.label || "").trim();
var label = String(category.Label || "").trim();
return label || categoryPlaceholder(index);
}
function categoryPlaceholder(index) {
var used = {};
categories().forEach(function (category) {
var label = String(category.Label || category.label || "").trim().toLowerCase();
var label = String(category.Label || "").trim().toLowerCase();
if (label) used[label] = true;
});
var n = Math.max(1, index + 1);
@@ -151,11 +174,10 @@
return "Category " + n;
}
function newCategory(label, criteria) {
function newCategory(label) {
return {
Id: randomId(),
Label: label || "",
CriteriaText: criteria || "",
Requirements: [emptyRequirement()],
MatchAllConditions: true,
Scopes: ["M", "S", "C"],
@@ -164,7 +186,7 @@
}
function emptyRequirement() {
return { Field: "", Fields: [], UseFieldOr: false, Relation: "", UseOr: false, Values: [] };
return { Fields: [], UseFieldOr: false, Relation: "", UseOr: false, Values: [] };
}
function normalizeActionValue(fieldId, value) {
@@ -177,13 +199,6 @@
return canonical ? "Language:" + canonical : "";
}
function legacyActionToList(fieldId, legacy) {
if (!legacy || legacy.toLowerCase() === "fallback") {
return ["Jellyfin"];
}
return [legacy];
}
function normalizeActionList(fieldId, values) {
var field = fields.find(function (f) { return f.id === fieldId; });
var result = [];
@@ -199,64 +214,31 @@
return result.length || !(field && field.lockedJ) ? result : ["Jellyfin"];
}
function normalizeActionMap(source, legacy) {
if (legacy && looksLikeDefaultActionMap(source) && hasMeaningfulLegacyActions(legacy)) {
source = null;
}
function normalizeActionMap(source) {
var result = {};
fields.forEach(function (field) {
var values = source && (source[field.id] || source[field.label]);
if (!values && legacy) values = legacyActionToList(field.id, legacy[field.id] || legacy[field.label]);
result[field.id] = normalizeActionList(field.id, Array.isArray(values) ? values : values ? [values] : ["Jellyfin"]);
result[field.id] = normalizeActionList(field.id, source[field.id]);
});
return result;
}
function looksLikeDefaultActionMap(source) {
if (!source) return true;
return fields.every(function (field) {
var values = source[field.id] || source[field.label];
return Array.isArray(values) && values.length === 1 && String(values[0]).toLowerCase() === "jellyfin";
});
}
function hasMeaningfulLegacyActions(legacy) {
return Object.keys(legacy || {}).some(function (key) {
var action = String(legacy[key] || "").toLowerCase();
return action && action !== "fallback" && action !== "jellyfin";
});
}
function normalizeRules() {
state.rules = state.rules || {};
state.rules.Categories = (state.rules.Categories || []).filter(function (category) {
var id = categoryId(category).toLowerCase();
var label = String(category.Label || category.label || "").trim().toLowerCase();
var criteria = String(category.CriteriaText || category.criteriaText || "").trim();
return !(id === "fallback" && label === "fallback" && criteria.length === 0);
}).map(function (category, index) {
category.Id = categoryId(category) || randomId();
category.Label = String(category.Label || category.label || "").trim();
category.CriteriaText = category.CriteriaText || category.criteriaText || "";
category.Requirements = normalizeRequirements(category.Requirements || category.requirements);
category.MatchAllConditions = category.MatchAllConditions !== false && category.matchAllConditions !== false;
category.Scopes = category.Scopes || category.scopes || ["M", "S", "C"];
category.FieldActionLists = normalizeActionMap(category.FieldActionLists || category.fieldActionLists, category.FieldActions || category.fieldActions);
return category;
state.rules.Categories.forEach(function (category) {
category.Requirements = normalizeRequirements(category.Requirements);
category.FieldActionLists = normalizeActionMap(category.FieldActionLists);
});
state.rules.FallbackFieldActions = normalizeActionMap(state.rules.FallbackFieldActions || state.rules.fallbackFieldActions, null);
state.rules.FallbackFieldActions = normalizeActionMap(state.rules.FallbackFieldActions);
}
function normalizeRequirements(requirements) {
var normalized = (requirements || []).map(function (requirement) {
var fields = normalizeRequirementFields(requirement.Fields || requirement.fields, requirement.Field || requirement.field);
var fields = normalizeRequirementFields(requirement.Fields);
return {
Field: fields[0] || "",
Fields: fields,
UseFieldOr: requirement.UseFieldOr === true || requirement.useFieldOr === true,
Relation: String(requirement.Relation || requirement.relation || "").trim(),
UseOr: requirement.UseOr === true || requirement.useOr === true,
Values: orderedRequirementValues(fields[0] || "", requirement.Values || requirement.values || [])
UseFieldOr: requirement.UseFieldOr === true,
Relation: String(requirement.Relation || "").trim(),
UseOr: requirement.UseOr === true,
Values: orderedRequirementValues(fields[0] || "", requirement.Values || [])
};
});
return normalized.length ? normalized : [emptyRequirement()];
@@ -282,14 +264,12 @@
return criteriaFields.find(function (item) { return item.id === id; });
}
function normalizeRequirementFields(values, legacy) {
function normalizeRequirementFields(values) {
var raw = [];
(values || []).forEach(function (value) {
value = String(value || "").trim();
if (value) raw.push(value);
});
legacy = String(legacy || "").trim();
if (legacy) raw.push(legacy);
var wanted = {};
raw.forEach(function (value) {
var id = value.toLowerCase();
@@ -307,7 +287,7 @@
}
function requirementFields(requirement) {
return normalizeRequirementFields(requirement && (requirement.Fields || requirement.fields), requirement && (requirement.Field || requirement.field));
return normalizeRequirementFields(requirement.Fields);
}
function criteriaLabel(id) {
@@ -317,7 +297,7 @@
function renderSortLocales() {
var select = $("ml-sort-locale");
select.replaceChildren(option("Auto", "Auto", false));
M.replaceChildren(select, option("Auto", "Auto", false));
state.allowed.forEach(function (lang) {
select.appendChild(option(lang, lang, false));
});
@@ -351,7 +331,6 @@
function syncCategoryFromRow(row, category) {
category.Label = row.querySelector("[data-category-label]").value;
category.MatchAllConditions = row.querySelector("[data-category-match-all]").checked;
category.CriteriaText = categoryCriteriaText(category);
category.Scopes = Array.prototype.slice.call(row.querySelectorAll("[data-category-scope]:checked"))
.map(function (input) { return input.value; });
}
@@ -364,7 +343,7 @@
}
function addSelectOptions(select, items, selectedValue) {
select.replaceChildren();
M.replaceChildren(select);
items.forEach(function (item) {
select.appendChild(option(item.label, item.id, item.id === selectedValue));
});
@@ -450,23 +429,6 @@
return values.join(joiner);
}
function requirementText(requirement) {
if (!completeRequirement(requirement)) return "";
var values = orderedRequirementValues(requirementFields(requirement)[0] || "", requirement.Values || []);
var right = values.length === 1 ? values[0] : "(" + values.join(requirement.UseOr ? " or " : " and ") + ")";
var fields = requirementFields(requirement);
var left = fields.length === 1 ? fields[0] : "(" + fields.join(requirement.UseFieldOr ? " or " : " and ") + ")";
return left + " " + requirement.Relation + " " + right;
}
function categoryCriteriaText(category) {
var joiner = category.MatchAllConditions === false ? " or " : " and ";
return (category.Requirements || [])
.filter(completeRequirement)
.map(requirementText)
.join(joiner);
}
function makeRequirementRow(category, requirement, index) {
var row = make("div", "ml-requirement-row");
var selectedFields = requirementFields(requirement);
@@ -512,7 +474,7 @@
var current = requirement.Relation || "";
var fields = requirementFields(requirement);
var multiple = fields.length > 1;
select.replaceChildren(option("Choose relation", "", !current));
M.replaceChildren(select, option("Choose relation", "", !current));
if (fields.length) {
relations.forEach(function (item) {
select.appendChild(option(multiple ? item.pluralLabel : item.label, item.id, item.id === current));
@@ -557,7 +519,6 @@
fields = normalizeRequirementFields(fields, "");
var nextKind = fields.length ? fieldKind(fields[0]) : "";
requirement.Fields = fields;
requirement.Field = fields[0] || "";
button.textContent = fieldSummary(requirement);
refreshFieldPanelOptions(panel, requirement);
if (onChange) onChange(previousKind, nextKind);
@@ -662,7 +623,6 @@
function cloneRequirements(requirements) {
return normalizeRequirements(requirements).map(function (requirement) {
return {
Field: requirement.Field,
Fields: (requirement.Fields || []).slice(),
UseFieldOr: requirement.UseFieldOr === true,
Relation: requirement.Relation,
@@ -676,7 +636,6 @@
return {
Id: randomId(),
Label: "",
CriteriaText: categoryCriteriaText(category),
Requirements: cloneRequirements(category.Requirements),
MatchAllConditions: category.MatchAllConditions !== false,
Scopes: (category.Scopes || ["M", "S", "C"]).slice(),
@@ -686,7 +645,7 @@
function renderCategories() {
var root = $("ml-categories");
root.replaceChildren();
M.replaceChildren(root);
categories().forEach(function (category, index) {
var row = make("div", "ml-classification-rule");
row.dataset.categoryId = categoryId(category);
@@ -797,7 +756,7 @@
function renderActionSource() {
var root = $("ml-action-source");
root.replaceChildren();
M.replaceChildren(root);
actionSourceValues().forEach(function (value) {
root.appendChild(makeActionToken(value, false, true));
});
@@ -819,7 +778,7 @@
function renderActionMatrix() {
renderActionSource();
var root = $("ml-action-matrix");
root.replaceChildren();
M.replaceChildren(root);
actionRows().forEach(function (row) {
var line = make("div", "ml-action-category-row");
if (row.fallback) line.classList.add("ml-action-category-row-fallback");
@@ -1056,7 +1015,7 @@
function clearCategoryActions(categoryId) {
boxesForCategory(categoryId).forEach(function (box) {
box.replaceChildren();
M.replaceChildren(box);
if (actionFieldLocksJ(box.dataset.fieldId)) box.appendChild(makeActionToken("Jellyfin", true, false));
});
}
@@ -1073,7 +1032,7 @@
return token.dataset.value !== "Jellyfin";
});
var token = makeActionToken(value, actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin", false);
box.replaceChildren();
M.replaceChildren(box);
if (prepend) box.appendChild(token);
existing.forEach(function (item) { box.appendChild(item); });
if (!prepend) box.appendChild(token);
@@ -1116,7 +1075,6 @@
categories().forEach(function (category) {
var complete = (category.Requirements || []).filter(completeRequirement);
category.Requirements = complete.length ? complete : [emptyRequirement()];
category.CriteriaText = complete.map(requirementText).join(" and ");
});
return {
Enabled: $("ml-enabled").checked,
@@ -1128,12 +1086,10 @@
return {
Id: category.Id,
Label: categoryLabel(category, index),
CriteriaText: complete.map(requirementText).join(category.MatchAllConditions === false ? " or " : " and "),
Requirements: complete,
MatchAllConditions: category.MatchAllConditions !== false,
Scopes: category.Scopes,
FieldActionLists: category.FieldActionLists,
FieldActions: {}
FieldActionLists: category.FieldActionLists
};
})
};
@@ -1171,7 +1127,7 @@
$("ml-user-import-modal").hidden = !show;
if (!show) {
$("ml-user-import-choice").hidden = true;
$("ml-user-import-source").replaceChildren();
M.replaceChildren($("ml-user-import-source"));
}
}
@@ -1239,7 +1195,7 @@
}
var select = $("ml-user-import-source");
select.replaceChildren();
M.replaceChildren(select);
ids.forEach(function (id) { select.appendChild(option(id, id, false)); });
choice.hidden = false;
M.setStatus(status, "No matching user ID was found. Choose which exported user settings to import.", true);
@@ -1260,7 +1216,18 @@
}
});
$("ml-reset-user").addEventListener("click", load);
$("ml-sort-locale").addEventListener("change", renderSortLocaleAutoText);
$("ml-sort-locale").addEventListener("change", function () {
state.rules.SortLocale = $("ml-sort-locale").value || "Auto";
renderSortLocaleAutoText();
});
$("ml-debug-open").addEventListener("click", function () {
var itemId = $("ml-debug-item-id").value.trim();
if (!itemId) {
M.setStatus(status, "Enter a Jellyfin item ID.", true);
return;
}
window.open(M.debugUrl(itemId), "_blank", "noopener");
});
$("ml-user-export").addEventListener("click", function () {
downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); });
});
@@ -1272,26 +1239,26 @@
$("ml-user-import-run").addEventListener("click", function () {
runUserImport().catch(function (err) { M.setStatus(status, String(err), true); });
});
document.addEventListener("click", function () { closeValuePanels(null); });
listen(document, "click", function () { closeValuePanels(null); });
$("ml-add-category").addEventListener("click", function () {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().push(newCategory("", ""));
categories().push(newCategory(""));
renderAll();
});
document.addEventListener("dragstart", function (event) {
listen(document, "dragstart", function (event) {
if (event.target.closest && event.target.closest(".ml-action-token")) event.preventDefault();
}, true);
page.addEventListener("pointerdown", beginActionDrag, true);
document.addEventListener("pointermove", moveActionDrag, true);
document.addEventListener("pointerup", endActionDrag, true);
document.addEventListener("pointercancel", function (event) {
listen(document, "pointermove", moveActionDrag, true);
listen(document, "pointerup", endActionDrag, true);
listen(document, "pointercancel", function (event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
}, true);
document.addEventListener("viewshow", function (event) {
listen(document, "viewshow", function (event) {
if (event.target && event.target.id === "ml-user") load();
});
}
@@ -42,13 +42,13 @@ public sealed partial class TranslationStore
delete.CommandText = @"
DELETE FROM facts WHERE item_id = $item_id;
DELETE FROM translations WHERE item_id = $item_id;
DELETE FROM assets WHERE item_id = $item_id;";
DELETE FROM assets WHERE item_id = $item_id;
DELETE FROM fetch_state WHERE item_id = $item_id;";
delete.Parameters.AddWithValue("$item_id", itemId);
delete.ExecuteNonQuery();
}
tx.Commit();
CleanupUnreferencedAssetFiles();
}
public LocalCleanupResult CleanupForConfiguration(IReadOnlySet<string> liveItemIds, IEnumerable<string> allowedLanguages, bool localAssetStorage)
@@ -62,10 +62,18 @@ DELETE FROM assets WHERE item_id = $item_id;";
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
using (var con = Open())
{
con.Open();
using var cmd = con.CreateCommand();
var parameters = AddParams(cmd, allowed, "$lang");
cmd.CommandText = $"DELETE FROM fetch_state WHERE source LIKE 'metadata:%' AND substr(source, 10) NOT IN ({parameters});";
cmd.ExecuteNonQuery();
}
var translationsDeleted = DeleteRowsOutsideLanguages("translations", allowed);
var genresDeleted = DeleteRowsOutsideLanguages("genres", allowed);
var assetsDeleted = DeleteRowsOutsideLanguages("assets", allowed);
var modeRowsDeleted = localAssetStorage ? 0 : DeleteLocalAssetRows(resetMissingCheckedAt: true);
var modeRowsDeleted = localAssetStorage ? 0 : SwitchLocalAssetsToUrls();
var assetFilesDeleted = CleanupUnreferencedAssetFiles();
return new LocalCleanupResult(translationsDeleted, assetsDeleted + modeRowsDeleted, genresDeleted, assetFilesDeleted);
}
@@ -80,38 +88,22 @@ DELETE FROM assets WHERE item_id = $item_id;";
return cmd.ExecuteNonQuery();
}
private int DeleteLocalAssetRows(bool resetMissingCheckedAt)
private int SwitchLocalAssetsToUrls()
{
using var con = Open();
con.Open();
using var tx = con.BeginTransaction();
var affected = new List<string>();
using (var select = con.CreateCommand())
{
select.Transaction = tx;
select.CommandText = "SELECT DISTINCT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%';";
using var reader = select.ExecuteReader();
while (reader.Read())
affected.Add(reader.GetString(0));
}
int deleted;
using (var delete = con.CreateCommand())
{
delete.Transaction = tx;
delete.CommandText = "DELETE FROM assets WHERE path_low LIKE '/multilang/assets/%';";
deleted = delete.ExecuteNonQuery();
}
if (resetMissingCheckedAt && affected.Count > 0)
{
using var update = con.CreateCommand();
update.Transaction = tx;
var idParams = AddParams(update, affected, "$item");
update.CommandText = $"UPDATE facts SET missing_checked_at = 0 WHERE item_id IN ({idParams});";
update.ExecuteNonQuery();
}
using var cmd = con.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"UPDATE assets SET path = source_url, path_low = lower(source_url)
WHERE path_low LIKE '/multilang/assets/%' AND source_url <> '';
DELETE FROM fetch_state WHERE source LIKE 'artwork:%' AND item_id IN
(SELECT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%');
UPDATE facts SET missing_checked_at = 0 WHERE item_id IN
(SELECT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%');";
cmd.ExecuteNonQuery();
cmd.CommandText = "DELETE FROM assets WHERE path_low LIKE '/multilang/assets/%';";
var deleted = cmd.ExecuteNonQuery();
tx.Commit();
return deleted;
}
@@ -163,13 +155,13 @@ DELETE FROM assets WHERE item_id = $item_id;";
public bool TryNormalizeLocalAssetPath(string path, out string fullPath)
{
fullPath = string.Empty;
if (string.IsNullOrWhiteSpace(path) || Uri.TryCreate(path, UriKind.Absolute, out _))
if (string.IsNullOrWhiteSpace(path))
return false;
var relative = path;
if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase))
relative = relative[LocalAssetUrlPrefix.Length..];
else if (Path.IsPathRooted(relative))
else if (Uri.TryCreate(relative, UriKind.Absolute, out _) || Path.IsPathRooted(relative))
return false;
var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative));
@@ -15,21 +15,34 @@ public sealed partial class TranslationStore
destination.Open();
using (var schema = destination.CreateCommand())
{
schema.CommandText = SchemaSql;
schema.CommandText = SchemaSql + "\nPRAGMA user_version = 1;";
schema.ExecuteNonQuery();
}
using var source = Open();
source.Open();
CopyTable(source, destination, "facts", "item_id, tmdb_id, kind, original_title, original_language, original_language_all, origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at");
CopyTable(source, destination, "translations", "item_id, lang, field, text");
CopyTable(source, destination, "assets", "item_id, lang, kind, path, path_low, updated_at");
CopyTable(source, destination, "genres", "tmdb_id, media, lang, name, name_norm");
CopyTable(source, destination, "scan_state", "id, last_scan_started");
using var snapshot = source.BeginTransaction();
CopyTable(snapshot, destination, "facts", "item_id, tmdb_id, kind, original_title, original_language, original_language_all, origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at");
CopyTable(snapshot, destination, "translations", "item_id, lang, field, text");
CopyTable(snapshot, destination, "assets", "item_id, lang, kind, path, path_low, updated_at, source_url, provider");
CopyTable(snapshot, destination, "fetch_state", "item_id, source, scope, checked_at, complete");
CopyTable(snapshot, destination, "genres", "tmdb_id, media, lang, name, name_norm");
CopyTable(snapshot, destination, "scan_state", "id, last_scan_started");
}
public static void ValidateTranslationBackup(string path)
{
using var connection = new SqliteConnection($"Data Source={path};Mode=ReadOnly");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "PRAGMA user_version;";
if (Convert.ToInt32(command.ExecuteScalar()) != 1)
throw new InvalidOperationException("Unsupported translations backup version. Export a new backup using the current plugin.");
}
public ImportDatabaseResult ImportTranslationDatabase(string sourcePath, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
{
ValidateTranslationBackup(sourcePath);
var allowed = allowedLanguages.ToHashSet(StringComparer.OrdinalIgnoreCase);
allowed.Add(MultilangConstants.OriginalAction);
@@ -39,187 +52,62 @@ public sealed partial class TranslationStore
source.Open();
using var tx = destination.BeginTransaction();
var facts = ImportFacts(source, destination, tx, liveItemIds);
var translations = ImportTranslations(source, destination, tx, liveItemIds, allowed);
var assets = ImportAssets(source, destination, tx, liveItemIds, allowed);
var genres = ImportGenres(source, destination, tx, allowed);
bool Live(SqliteDataReader row) => liveItemIds.Contains(row.GetString(0));
bool Translation(SqliteDataReader row) => Live(row) && allowed.Contains(row.GetString(1));
var facts = ImportTable(source, tx, "facts", "item_id, tmdb_id, kind, original_title, original_language, original_language_all, origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at", Live);
var translations = ImportTable(source, tx, "translations", "item_id, lang, field, text", Translation);
var assets = ImportTable(source, tx, "assets", "item_id, lang, kind, path, path_low, updated_at, source_url, provider", Translation);
var genres = ImportTable(source, tx, "genres", "tmdb_id, media, lang, name, name_norm", row => allowed.Contains(row.GetString(2)));
ImportTable(source, tx, "fetch_state", "item_id, source, scope, checked_at, complete", row =>
Live(row) && (row.GetString(1).StartsWith("artwork:") || (row.GetString(1).StartsWith("metadata:", StringComparison.Ordinal) && allowed.Contains(row.GetString(1)["metadata:".Length..]))));
tx.Commit();
return new ImportDatabaseResult(facts, translations, assets, genres);
}
private static void CopyTable(SqliteConnection source, SqliteConnection destination, string table, string columns)
private static void CopyTable(SqliteTransaction snapshot, SqliteConnection destination, string table, string columns)
{
using var read = source.CreateCommand();
using var read = snapshot.Connection!.CreateCommand();
read.Transaction = snapshot;
read.CommandText = $"SELECT {columns} FROM {table};";
using var reader = read.ExecuteReader();
using var tx = destination.BeginTransaction();
while (reader.Read())
{
var names = columns.Split(',', StringSplitOptions.TrimEntries);
using var insert = destination.CreateCommand();
insert.Transaction = tx;
var names = columns.Split(',', StringSplitOptions.TrimEntries);
insert.CommandText = $"INSERT OR REPLACE INTO {table}({columns}) VALUES({string.Join(",", names.Select((_, i) => "$v" + i.ToString(CultureInfo.InvariantCulture)))});";
for (var i = 0; i < names.Length; i++)
insert.Parameters.AddWithValue("$v" + i.ToString(CultureInfo.InvariantCulture), reader.GetValue(i));
insert.Parameters.Add(new SqliteParameter("$v" + i.ToString(CultureInfo.InvariantCulture), DBNull.Value));
while (reader.Read())
{
for (var i = 0; i < names.Length; i++)
insert.Parameters[i].Value = reader.GetValue(i);
insert.ExecuteNonQuery();
}
tx.Commit();
}
private static int ImportFacts(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> liveItemIds)
private static int ImportTable(SqliteConnection source, SqliteTransaction tx, string table, string columns, Func<SqliteDataReader, bool> include)
{
using var read = source.CreateCommand();
read.CommandText = @"
SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at
FROM facts;";
read.CommandText = $"SELECT {columns} FROM {table};";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var itemId = reader.GetString(0);
if (!liveItemIds.Contains(itemId))
continue;
using var insert = destination.CreateCommand();
using var insert = tx.Connection!.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at)
VALUES($item_id, $tmdb_id, $kind, $original_title, $original_language, $original_language_all,
$origin_countries_json, $production_countries_json, $spoken_languages_json, $audio_track_language,
$genre_tmdb_ids_json, $missing_checked_at, $full_checked_at)
ON CONFLICT(item_id) DO UPDATE SET
tmdb_id = excluded.tmdb_id,
kind = excluded.kind,
original_title = excluded.original_title,
original_language = excluded.original_language,
original_language_all = excluded.original_language_all,
origin_countries_json = excluded.origin_countries_json,
production_countries_json = excluded.production_countries_json,
spoken_languages_json = excluded.spoken_languages_json,
audio_track_language = excluded.audio_track_language,
genre_tmdb_ids_json = excluded.genre_tmdb_ids_json,
missing_checked_at = excluded.missing_checked_at,
full_checked_at = excluded.full_checked_at;";
var names = new[]
{
"$item_id",
"$tmdb_id",
"$kind",
"$original_title",
"$original_language",
"$original_language_all",
"$origin_countries_json",
"$production_countries_json",
"$spoken_languages_json",
"$audio_track_language",
"$genre_tmdb_ids_json",
"$missing_checked_at",
"$full_checked_at"
};
var names = columns.Split(',', StringSplitOptions.TrimEntries);
insert.CommandText = $"INSERT OR REPLACE INTO {table}({columns}) VALUES({string.Join(",", names.Select((_, i) => "$v" + i))});";
for (var i = 0; i < names.Length; i++)
insert.Parameters.AddWithValue(names[i], reader.GetValue(i));
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static int ImportTranslations(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
{
using var read = source.CreateCommand();
read.CommandText = "SELECT item_id, lang, field, text FROM translations;";
using var reader = read.ExecuteReader();
insert.Parameters.Add(new SqliteParameter("$v" + i, DBNull.Value));
var count = 0;
while (reader.Read())
{
var itemId = reader.GetString(0);
var lang = reader.GetString(1);
if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang))
if (!include(reader))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO translations(item_id, lang, field, text)
VALUES($item_id, $lang, $field, $text)
ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;";
insert.Parameters.AddWithValue("$item_id", itemId);
insert.Parameters.AddWithValue("$lang", lang);
insert.Parameters.AddWithValue("$field", reader.GetString(2));
insert.Parameters.AddWithValue("$text", reader.GetString(3));
for (var i = 0; i < names.Length; i++)
insert.Parameters[i].Value = reader.GetValue(i);
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static int ImportAssets(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
{
using var read = source.CreateCommand();
read.CommandText = "SELECT item_id, lang, kind, path, path_low, updated_at FROM assets;";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var itemId = reader.GetString(0);
var lang = reader.GetString(1);
if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at)
VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at)
ON CONFLICT(item_id, lang, kind) DO UPDATE SET
path = excluded.path,
path_low = excluded.path_low,
updated_at = excluded.updated_at;";
insert.Parameters.AddWithValue("$item_id", itemId);
insert.Parameters.AddWithValue("$lang", lang);
insert.Parameters.AddWithValue("$kind", reader.GetString(2));
insert.Parameters.AddWithValue("$path", reader.GetString(3));
insert.Parameters.AddWithValue("$path_low", reader.GetString(4));
insert.Parameters.AddWithValue("$updated_at", reader.GetInt64(5));
insert.ExecuteNonQuery();
count++;
}
return count;
}
private static int ImportGenres(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet<string> allowedLanguages)
{
using var read = source.CreateCommand();
read.CommandText = "SELECT tmdb_id, media, lang, name, name_norm FROM genres;";
using var reader = read.ExecuteReader();
var count = 0;
while (reader.Read())
{
var lang = reader.GetString(2);
if (!allowedLanguages.Contains(lang))
continue;
using var insert = destination.CreateCommand();
insert.Transaction = tx;
insert.CommandText = @"
INSERT INTO genres(tmdb_id, media, lang, name, name_norm)
VALUES($tmdb_id, $media, $lang, $name, $name_norm)
ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET
name = excluded.name,
name_norm = excluded.name_norm;";
insert.Parameters.AddWithValue("$tmdb_id", reader.GetInt32(0));
insert.Parameters.AddWithValue("$media", reader.GetString(1));
insert.Parameters.AddWithValue("$lang", lang);
insert.Parameters.AddWithValue("$name", reader.GetString(3));
insert.Parameters.AddWithValue("$name_norm", reader.GetString(4));
insert.ExecuteNonQuery();
count++;
}
return count;
}
}
@@ -4,8 +4,6 @@ namespace Jellyfin.Plugin.Multilang.Data;
public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt);
public readonly record struct AssetPresence(bool Any, bool AnyRemote);
public readonly record struct LocalCleanupResult(int TranslationsDeleted, int AssetsDeleted, int GenresDeleted, int AssetFilesDeleted);
public readonly record struct ImportDatabaseResult(int Facts, int Translations, int Assets, int Genres);
@@ -46,23 +44,17 @@ public sealed class UserCategoryRule
public string Label { get; set; } = string.Empty;
public string CriteriaText { get; set; } = string.Empty;
public UserCategoryRequirement[] Requirements { get; set; } = [];
public bool MatchAllConditions { get; set; } = true;
public string[] Scopes { get; set; } = ["M", "S", "C"];
public Dictionary<string, string> FieldActions { get; set; } = MultilangConstants.DefaultFieldActions();
public Dictionary<string, string[]> FieldActionLists { get; set; } = MultilangConstants.DefaultActionLists();
}
public sealed class UserCategoryRequirement
{
public string Field { get; set; } = string.Empty;
public string[] Fields { get; set; } = [];
public bool UseFieldOr { get; set; }
@@ -0,0 +1,125 @@
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using Microsoft.Data.Sqlite;
using static Jellyfin.Plugin.Multilang.MultilangConstants;
namespace Jellyfin.Plugin.Multilang.Data;
public sealed record FetchState(string Scope, long CheckedAt, bool Complete);
public sealed record StoredAsset(string Language, string Kind, string Path, string SourceUrl, string Provider);
public sealed partial class TranslationStore
{
private void Write(SqliteTransaction? transaction, Action<SqliteTransaction> action)
{
if (transaction is not null)
{
action(transaction);
return;
}
using var connection = Open();
connection.Open();
using var tx = connection.BeginTransaction();
action(tx);
tx.Commit();
}
public Dictionary<string, FetchState> GetFetchStates(string itemId)
{
using var connection = Open();
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT source, scope, checked_at, complete FROM fetch_state WHERE item_id = $id;";
cmd.Parameters.AddWithValue("$id", itemId);
using var reader = cmd.ExecuteReader();
var result = new Dictionary<string, FetchState>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
result.Add(reader.GetString(0), new(reader.GetString(1), reader.GetInt64(2), reader.GetBoolean(3)));
return result;
}
private static void SaveFetchState(SqliteTransaction tx, string itemId, string source, FetchState state)
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "INSERT OR REPLACE INTO fetch_state(item_id, source, scope, checked_at, complete) VALUES($id, $source, $scope, $time, $complete);";
cmd.Parameters.AddWithValue("$id", itemId);
cmd.Parameters.AddWithValue("$source", source);
cmd.Parameters.AddWithValue("$scope", state.Scope);
cmd.Parameters.AddWithValue("$time", state.CheckedAt);
cmd.Parameters.AddWithValue("$complete", state.Complete);
cmd.ExecuteNonQuery();
}
public void SaveMetadata(RefreshItemInfo item, string language, TmdbMetadata? metadata, FetchState state, bool full, bool updateFacts = true)
=> Write(null, tx =>
{
if (metadata is not null)
{
if (updateFacts)
UpsertFacts(item, metadata, state.CheckedAt, full ? state.CheckedAt : 0, tx);
UpsertTranslation(item.ItemId, language, TitleField, metadata.Title, tx);
UpsertTranslation(item.ItemId, language, OverviewField, metadata.Overview, tx);
UpsertTranslation(item.ItemId, language, TaglineField, metadata.Tagline, tx);
foreach (var genre in metadata.Genres)
UpsertGenre(genre.Id, item.Kind is "movie" or "collection" ? "movie" : "tv", language, genre.Name, tx);
}
else
MarkFactsChecked(item, state.CheckedAt, full ? state.CheckedAt : 0, tx);
SaveFetchState(tx, item.ItemId, "metadata:" + language, state);
});
public void CopyOriginalTranslations(string itemId, string language)
=> Write(null, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"DELETE FROM translations WHERE item_id = $id AND lang = 'Original';
INSERT INTO translations(item_id, lang, field, text)
SELECT item_id, 'Original', field, text FROM translations WHERE item_id = $id AND lang = $lang;";
cmd.Parameters.AddWithValue("$id", itemId);
cmd.Parameters.AddWithValue("$lang", language);
cmd.ExecuteNonQuery();
});
public List<StoredAsset> GetStoredAssets(string itemId)
{
using var connection = Open();
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT lang, kind, path, source_url, provider FROM assets WHERE item_id = $id;";
cmd.Parameters.AddWithValue("$id", itemId);
using var reader = cmd.ExecuteReader();
var result = new List<StoredAsset>();
while (reader.Read())
result.Add(new(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4)));
return result;
}
public void SaveArtwork(string itemId, IEnumerable<StoredAsset> assets, IReadOnlyDictionary<string, FetchState> states)
=> Write(null, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "DELETE FROM assets WHERE item_id = $id;";
cmd.Parameters.AddWithValue("$id", itemId);
cmd.ExecuteNonQuery();
cmd.CommandText = @"INSERT INTO assets(item_id, lang, kind, path, path_low, source_url, provider, updated_at)
VALUES($id, $lang, $kind, $path, $low, $url, $provider, $time);";
foreach (var name in new[] { "$lang", "$kind", "$path", "$low", "$url", "$provider", "$time" })
cmd.Parameters.Add(new SqliteParameter(name, DBNull.Value));
foreach (var asset in assets)
{
cmd.Parameters["$lang"].Value = asset.Language;
cmd.Parameters["$kind"].Value = asset.Kind;
cmd.Parameters["$path"].Value = asset.Path;
cmd.Parameters["$low"].Value = asset.Path.ToLowerInvariant();
cmd.Parameters["$url"].Value = asset.SourceUrl;
cmd.Parameters["$provider"].Value = asset.Provider;
cmd.Parameters["$time"].Value = NowUnixUtc();
cmd.ExecuteNonQuery();
}
foreach (var (provider, state) in states)
SaveFetchState(tx, itemId, provider, state);
});
}
@@ -38,11 +38,22 @@ CREATE TABLE IF NOT EXISTS assets (
kind TEXT NOT NULL,
path TEXT NOT NULL,
path_low TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY (item_id, lang, kind)
);
CREATE INDEX IF NOT EXISTS idx_assets_item ON assets(item_id);
CREATE TABLE IF NOT EXISTS fetch_state (
item_id TEXT NOT NULL,
source TEXT NOT NULL,
scope TEXT NOT NULL,
checked_at INTEGER NOT NULL,
complete INTEGER NOT NULL,
PRIMARY KEY (item_id, source)
);
CREATE TABLE IF NOT EXISTS genres (
tmdb_id INTEGER NOT NULL,
media TEXT NOT NULL,
@@ -15,6 +15,34 @@ public sealed partial class TranslationStore
private readonly string _dataDir;
private readonly string _assetsDir;
private readonly string _dbPath;
private readonly SemaphoreSlim _maintenance = new(1, 1);
private readonly SemaphoreSlim _refreshSlots = new(MultilangConstants.RefreshConcurrency, MultilangConstants.RefreshConcurrency);
public Task<IDisposable> EnterRefreshAsync(CancellationToken ct) => EnterAsync(1, ct);
public Task<IDisposable> EnterMaintenanceAsync(CancellationToken ct) => EnterAsync(MultilangConstants.RefreshConcurrency, ct);
private async Task<IDisposable> EnterAsync(int slots, CancellationToken ct)
{
await _maintenance.WaitAsync(ct).ConfigureAwait(false);
var acquired = 0;
try
{
for (; acquired < slots; acquired++)
await _refreshSlots.WaitAsync(ct).ConfigureAwait(false);
return new MaintenanceLease(_refreshSlots, slots);
}
catch
{
if (acquired > 0) _refreshSlots.Release(acquired);
throw;
}
finally { _maintenance.Release(); }
}
private sealed class MaintenanceLease(SemaphoreSlim semaphore, int slots) : IDisposable
{
public void Dispose() => semaphore.Release(slots);
}
public TranslationStore(IApplicationPaths appPaths)
{
@@ -24,6 +52,8 @@ public sealed partial class TranslationStore
Directory.CreateDirectory(_assetsDir);
_dbPath = Path.Combine(_dataDir, "translations.sqlite");
Initialize();
if (Plugin.Instance is { } plugin)
plugin.Store = this;
}
public string DataDirectory => _dataDir;
@@ -42,27 +72,38 @@ public sealed partial class TranslationStore
=> JsonSerializer.Serialize(values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
public SqliteConnection Open()
=> new($"Data Source={_dbPath};Cache=Shared;Pooling=False");
=> new($"Data Source={_dbPath};Pooling=False");
private void Initialize()
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode=WAL;";
cmd.ExecuteNonQuery();
cmd.CommandText = SchemaSql;
cmd.ExecuteNonQuery();
EnsureColumn(con, "assets", "path_low", "TEXT NOT NULL DEFAULT ''");
EnsureColumn(con, "assets", "source_url", "TEXT NOT NULL DEFAULT ''");
EnsureColumn(con, "assets", "provider", "TEXT NOT NULL DEFAULT ''");
cmd.CommandText = "UPDATE assets SET source_url = path WHERE source_url = '' AND (path_low LIKE 'https://%' OR path_low LIKE 'http://%');";
cmd.ExecuteNonQuery();
}
public void ResetAll()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(_dataDir))
Directory.Delete(_dataDir, recursive: true);
Directory.CreateDirectory(_dataDir);
using var con = Open();
con.Open();
using var tx = con.BeginTransaction();
using var cmd = con.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "DELETE FROM facts; DELETE FROM translations; DELETE FROM assets; DELETE FROM fetch_state; DELETE FROM genres; " +
"DELETE FROM user_rules; DELETE FROM user_display_langs; UPDATE scan_state SET last_scan_started = 0;";
cmd.ExecuteNonQuery();
tx.Commit();
if (Directory.Exists(_assetsDir))
Directory.Delete(_assetsDir, recursive: true);
Directory.CreateDirectory(_assetsDir);
Initialize();
SqliteConnection.ClearAllPools();
}
public (long DatabaseBytes, long AssetsBytes) GetStorageUsage()
@@ -147,11 +188,12 @@ ON CONFLICT(id) DO UPDATE SET last_scan_started = excluded.last_scan_started;";
return result;
}
public void UpsertFacts(RefreshItemInfo item, TmdbMetadata metadata, long missingCheckedAt, long fullCheckedAt)
public void UpsertFacts(RefreshItemInfo item, TmdbMetadata metadata, long missingCheckedAt, long fullCheckedAt, SqliteTransaction? transaction = null)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
Write(transaction, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO facts(
item_id, tmdb_id, kind, original_title, original_language, original_language_all,
@@ -188,13 +230,15 @@ ON CONFLICT(item_id) DO UPDATE SET
cmd.Parameters.AddWithValue("$missing_checked_at", missingCheckedAt);
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
cmd.ExecuteNonQuery();
});
}
public void MarkFactsChecked(RefreshItemInfo item, long missingCheckedAt, long fullCheckedAt)
public void MarkFactsChecked(RefreshItemInfo item, long missingCheckedAt, long fullCheckedAt, SqliteTransaction? transaction = null)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
Write(transaction, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO facts(
item_id, tmdb_id, kind, original_title, original_language, original_language_all,
@@ -215,6 +259,7 @@ ON CONFLICT(item_id) DO UPDATE SET
cmd.Parameters.AddWithValue("$missing_checked_at", missingCheckedAt);
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
cmd.ExecuteNonQuery();
});
}
public FactsData? GetFacts(string itemId)
@@ -290,11 +335,12 @@ WHERE item_id IN ({idParams});";
return rows.ToArray();
}
public void UpsertTranslation(string itemId, string lang, string field, string text)
public void UpsertTranslation(string itemId, string lang, string field, string text, SqliteTransaction? transaction = null)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
Write(transaction, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO translations(item_id, lang, field, text)
VALUES($item_id, $lang, $field, $text)
@@ -304,27 +350,32 @@ ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;";
cmd.Parameters.AddWithValue("$field", field);
cmd.Parameters.AddWithValue("$text", text);
cmd.ExecuteNonQuery();
});
}
public void UpsertAsset(string itemId, string lang, string kind, string path, long updatedAt)
public void UpsertAsset(string itemId, string lang, string kind, string path, long updatedAt, SqliteTransaction? transaction = null)
{
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
Write(transaction, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at)
VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at)
INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at, source_url)
VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at, $source_url)
ON CONFLICT(item_id, lang, kind) DO UPDATE SET
path = excluded.path,
path_low = excluded.path_low,
source_url = excluded.source_url,
updated_at = excluded.updated_at;";
cmd.Parameters.AddWithValue("$item_id", itemId);
cmd.Parameters.AddWithValue("$lang", lang);
cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$path", path);
cmd.Parameters.AddWithValue("$path_low", path.ToLowerInvariant());
cmd.Parameters.AddWithValue("$source_url", Uri.TryCreate(path, UriKind.Absolute, out var url) && url.Scheme is "https" or "http" ? path : "");
cmd.Parameters.AddWithValue("$updated_at", updatedAt);
cmd.ExecuteNonQuery();
});
}
public string? GetAssetPath(string itemId, string lang, string kind)
@@ -339,64 +390,26 @@ ON CONFLICT(item_id, lang, kind) DO UPDATE SET
return cmd.ExecuteScalar() as string;
}
public AssetPresence GetAssetPresence(string itemId, IEnumerable<string> languages)
public bool IsAssetReferenced(string itemId, string path)
{
var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (langs.Length == 0)
return new AssetPresence(false, false);
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var langParams = AddParams(cmd, langs, "$lang");
cmd.CommandText = $"SELECT path_low FROM assets WHERE item_id = $item_id AND lang IN ({langParams});";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
var any = false;
var anyRemote = false;
while (reader.Read())
{
any = true;
var path = reader.GetString(0);
if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || path.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
anyRemote = true;
cmd.CommandText = "SELECT 1 FROM assets WHERE item_id = $item AND path = $path LIMIT 1;";
cmd.Parameters.AddWithValue("$item", itemId);
cmd.Parameters.AddWithValue("$path", path);
return cmd.ExecuteScalar() is not null;
}
return new AssetPresence(any, anyRemote);
}
public bool HasMissingConfiguredTranslations(string itemId, IEnumerable<string> languages)
{
var langs = languages.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (langs.Length == 0)
return false;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
var langParams = AddParams(cmd, langs, "$lang");
cmd.CommandText = $@"
SELECT lang, COUNT(DISTINCT field)
FROM translations
WHERE item_id = $item_id AND lang IN ({langParams}) AND field IN ('title', 'overview', 'tagline')
GROUP BY lang;";
cmd.Parameters.AddWithValue("$item_id", itemId);
using var reader = cmd.ExecuteReader();
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
counts[reader.GetString(0)] = reader.GetInt32(1);
return langs.Any(lang => !counts.TryGetValue(lang, out var count) || count < 3);
}
public void UpsertGenre(int tmdbId, string media, string lang, string name)
public void UpsertGenre(int tmdbId, string media, string lang, string name, SqliteTransaction? transaction = null)
{
if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name))
return;
using var con = Open();
con.Open();
using var cmd = con.CreateCommand();
Write(transaction, tx =>
{
using var cmd = tx.Connection!.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = @"
INSERT INTO genres(tmdb_id, media, lang, name, name_norm)
VALUES($tmdb_id, $media, $lang, $name, $name_norm)
@@ -409,6 +422,7 @@ ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET
cmd.Parameters.AddWithValue("$name", name.Trim());
cmd.Parameters.AddWithValue("$name_norm", name.Trim().ToLowerInvariant());
cmd.ExecuteNonQuery();
});
}
public GenreRow[] GetGenres(string media)
@@ -2,13 +2,14 @@
<PropertyGroup>
<RootNamespace>Jellyfin.Plugin.Multilang</RootNamespace>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<Version>0.2.0</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion>
<FileVersion>0.2.0.0</FileVersion>
<Version>0.3.4</Version>
<AssemblyVersion>0.3.4.0</AssemblyVersion>
<FileVersion>0.3.4.0</FileVersion>
<Authors>ajp_anton</Authors>
</PropertyGroup>
@@ -17,13 +18,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.9">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.9">
<PackageReference Include="Jellyfin.Model" Version="12.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
</ItemGroup>
<ItemGroup>
@@ -2,6 +2,7 @@ namespace Jellyfin.Plugin.Multilang;
internal static class MultilangConstants
{
public const int RefreshConcurrency = 4;
public const string JellyfinAction = "Jellyfin";
public const string OriginalAction = "Original";
public const string FallbackAction = "Fallback";
@@ -25,12 +26,6 @@ internal static class MultilangConstants
=> field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) ||
field.Equals(PosterKind, StringComparison.OrdinalIgnoreCase);
public static Dictionary<string, string> DefaultFieldActions()
=> ActionFields.ToDictionary(
field => field,
_ => FallbackAction,
StringComparer.OrdinalIgnoreCase);
public static Dictionary<string, string[]> DefaultActionLists()
=> ActionFields.ToDictionary(
field => field,
+4
View File
@@ -10,6 +10,8 @@ namespace Jellyfin.Plugin.Multilang;
public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
internal Data.TranslationStore? Store { get; set; }
internal bool Uninstalling { get; private set; }
public static Plugin? Instance { get; private set; }
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
@@ -24,6 +26,8 @@ public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
public override void OnUninstalling()
{
Uninstalling = true;
using var maintenance = Store?.EnterMaintenanceAsync(CancellationToken.None).GetAwaiter().GetResult();
WebScriptInjector.RemoveInjected(ApplicationPaths.WebPath);
if (Configuration.CleanupDataOnUninstall)
@@ -17,6 +17,8 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
{
services.AddMvc().AddApplicationPart(typeof(Plugin).Assembly);
services.AddHttpClient("Multilang.Jellyfin")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
services.AddSingleton<TranslationStore>();
services.AddSingleton<ProviderCatalog>();
@@ -25,7 +27,11 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
services.AddSingleton<FanartClient>();
services.AddSingleton<RefreshCoordinator>();
services.AddSingleton<RefreshService>();
services.AddSingleton<ItemFetcher>();
services.AddSingleton<ItemsProxyCache>();
services.AddHostedService<ItemsProxyInvalidationService>();
services.AddSingleton<ItemsProxyRequestCoalescer>();
services.AddSingleton<ItemsProxyPrecacheService>();
services.AddSingleton<ItemsProxyTransformer>();
services.AddSingleton<AssetStorageService>();
services.AddSingleton<MultilangBackupService>();
@@ -10,10 +10,8 @@ public sealed record CategoryRuleTrace(
string[] Scopes,
bool ScopeMatches,
bool MatchAllConditions,
bool UsesStructuredRequirements,
bool Result,
RequirementTrace[] Requirements,
string CriteriaText);
RequirementTrace[] Requirements);
public sealed record RequirementTrace(
string[] Fields,
@@ -33,59 +31,40 @@ public static class CategoryRuleEvaluator
{
public static bool Matches(UserCategoryRule category, FactsData facts)
{
return Trace(category, facts).Result;
if (!ScopeMatches(category, facts.Kind))
return false;
if (category.Requirements is not { Length: > 0 })
return false;
return category.MatchAllConditions
? category.Requirements.All(requirement => MatchesRequirement(requirement, facts))
: category.Requirements.Any(requirement => MatchesRequirement(requirement, facts));
}
private static bool MatchesRequirement(UserCategoryRequirement requirement, FactsData facts)
{
var fields = RequirementFields(requirement);
var expected = NormalizeValues(requirement.Values ?? []);
if (fields.Length == 0 || expected.Length == 0 || string.IsNullOrWhiteSpace(requirement.Relation))
return false;
bool Match(string field) => CompareRequirement(NormalizeValues(FieldValues(field, facts)),
expected, requirement.Relation.Trim(), requirement.UseOr);
return requirement.UseFieldOr ? fields.Any(Match) : fields.All(Match);
}
private static string[] NormalizeValues(IEnumerable<string> values)
=> values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
public static CategoryRuleTrace Trace(UserCategoryRule category, FactsData facts)
{
var scopeMatches = ScopeMatches(category, facts.Kind);
if (!scopeMatches)
{
return new CategoryRuleTrace(
category.Id,
category.Label,
facts.Kind,
category.Scopes ?? [],
false,
category.MatchAllConditions,
category.Requirements is { Length: > 0 },
false,
[],
category.CriteriaText ?? string.Empty);
}
if (category.Requirements is { Length: > 0 })
{
var requirements = category.Requirements.Select(requirement => TraceRequirement(requirement, facts)).ToArray();
var result = category.MatchAllConditions
var result = scopeMatches && requirements.Length > 0 && (category.MatchAllConditions
? requirements.All(requirement => requirement.Result)
: requirements.Any(requirement => requirement.Result);
return new CategoryRuleTrace(
category.Id,
category.Label,
facts.Kind,
category.Scopes ?? [],
true,
category.MatchAllConditions,
true,
result,
requirements,
category.CriteriaText ?? string.Empty);
}
var criteria = (category.CriteriaText ?? string.Empty).Trim();
var criteriaResult = criteria.Length > 0 && EvaluateCriteria(criteria, facts);
return new CategoryRuleTrace(
category.Id,
category.Label,
facts.Kind,
category.Scopes ?? [],
true,
category.MatchAllConditions,
false,
criteriaResult,
[],
criteria);
: requirements.Any(requirement => requirement.Result));
return new(category.Id, category.Label, facts.Kind, category.Scopes,
scopeMatches, category.MatchAllConditions, result, requirements);
}
public static string[] ParseJsonStringArray(string json)
@@ -120,42 +99,6 @@ public static class CategoryRuleEvaluator
return scopes.Contains(wanted, StringComparer.OrdinalIgnoreCase);
}
private static bool EvaluateCriteria(string criteria, FactsData facts)
{
var orGroups = SplitByOperator(criteria, "or");
foreach (var group in orGroups)
{
var terms = SplitByOperator(group, "and");
if (terms.All(term => EvaluateTerm(term, facts)))
return true;
}
return false;
}
private static bool EvaluateTerm(string term, FactsData facts)
{
term = term.Trim();
if (term.StartsWith("not ", StringComparison.OrdinalIgnoreCase))
return !EvaluateTerm(term[4..], facts);
var relation = FindRelation(term);
if (relation is null)
return false;
var field = term[..relation.Value.Index].Trim();
var rawValue = term[(relation.Value.Index + relation.Value.Token.Length)..].Trim().Trim('"', '\'');
var result = relation.Value.Token switch
{
"contains" => FieldValues(field, facts).Any(v => ValueMatches(v, rawValue, IsLanguageField(field))),
"not_contains" => !FieldValues(field, facts).Any(v => ValueMatches(v, rawValue, IsLanguageField(field))),
"is" => FieldValues(field, facts) is { Length: 1 } values && ValueMatches(values[0], rawValue, IsLanguageField(field)),
"is_not" => !(FieldValues(field, facts) is { Length: 1 } values && ValueMatches(values[0], rawValue, IsLanguageField(field))),
_ => false
};
return result;
}
private static RequirementTrace TraceRequirement(UserCategoryRequirement requirement, FactsData facts)
{
var fields = RequirementFields(requirement);
@@ -201,7 +144,11 @@ public static class CategoryRuleEvaluator
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var result = relation switch
return new RequirementFieldTrace(field, actual, CompareRequirement(actual, expected, relation, useOr));
}
private static bool CompareRequirement(string[] actual, string[] expected, string relation, bool useOr)
=> relation switch
{
"is" => RequirementIs(actual, expected, useOr),
"is_not" => !RequirementIs(actual, expected, useOr),
@@ -209,23 +156,9 @@ public static class CategoryRuleEvaluator
"not_contains" => !RequirementContains(actual, expected, useOr),
_ => false
};
return new RequirementFieldTrace(field, actual, result);
}
private static string[] RequirementFields(UserCategoryRequirement requirement)
{
var fields = (requirement.Fields ?? [])
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (fields.Length > 0)
return fields;
var field = (requirement.Field ?? string.Empty).Trim();
return field.Length == 0 ? [] : [field];
}
=> NormalizeValues(requirement.Fields);
private static bool RequirementIs(string[] actual, string[] expected, bool useOr)
{
@@ -252,19 +185,6 @@ public static class CategoryRuleEvaluator
private static bool EqualsValue(string actual, string expected)
=> string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
private static (string Token, int Index)? FindRelation(string term)
{
foreach (var token in new[] { "not_contains", "contains", "is_not", "is" })
{
var needle = " " + token + " ";
var index = term.IndexOf(needle, StringComparison.OrdinalIgnoreCase);
if (index >= 0)
return (token, index + 1);
}
return null;
}
private static string[] FieldValues(string field, FactsData facts)
{
field = field.Trim().ToLowerInvariant();
@@ -279,44 +199,4 @@ public static class CategoryRuleEvaluator
};
}
private static bool IsLanguageField(string field)
=> field.Equals("spoken_languages", StringComparison.OrdinalIgnoreCase) ||
field.Equals("original_language", StringComparison.OrdinalIgnoreCase) ||
field.Equals("audio_language", StringComparison.OrdinalIgnoreCase);
private static bool ValueMatches(string actual, string expected, bool language)
{
if (expected.Equals("empty", StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(actual);
if (!language)
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
if (expected.Contains('-', StringComparison.Ordinal))
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) ||
actual.StartsWith(expected + "-", StringComparison.OrdinalIgnoreCase);
}
private static string[] SplitByOperator(string text, string op)
{
var parts = new List<string>();
var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var current = new List<string>();
foreach (var token in tokens)
{
if (token.Equals(op, StringComparison.OrdinalIgnoreCase))
{
parts.Add(string.Join(' ', current));
current.Clear();
}
else
{
current.Add(token);
}
}
if (current.Count > 0)
parts.Add(string.Join(' ', current));
return parts.Where(p => p.Trim().Length > 0).ToArray();
}
}
@@ -0,0 +1,166 @@
using Jellyfin.Plugin.Multilang.Data;
using static Jellyfin.Plugin.Multilang.MultilangConstants;
namespace Jellyfin.Plugin.Multilang.Rules;
public static class UserRulesNormalizer
{
private static readonly string[] RequirementFieldOrder =
[
"original_language",
"spoken_languages",
"audio_language",
"origin_countries",
"production_countries"
];
public static UserRulesDocument Normalize(UserRulesDocument rules, IEnumerable<string>? languages = null)
{
var allowed = (languages ?? (rules.Categories ?? []).SelectMany(c => (c.FieldActionLists ?? []).Values)
.Concat((rules.FallbackFieldActions ?? []).Values).SelectMany(actions => actions ?? [])
.Where(action => action is not null && action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)).Select(action => action[LanguagePrefix.Length..]))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
rules.Categories = NormalizeCategories(rules.Categories, allowed);
rules.FallbackFieldActions = NormalizeActionLists(rules.FallbackFieldActions, allowed);
return rules;
}
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(),
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(),
FieldActionLists = NormalizeActionLists(category.FieldActionLists, allowedLanguages)
})
.ToArray();
private static UserCategoryRequirement[] NormalizeRequirements(IEnumerable<UserCategoryRequirement>? requirements)
=> (requirements ?? [])
.Select(requirement =>
{
var fields = NormalizeRequirementFields(requirement.Fields);
return new UserCategoryRequirement
{
Fields = fields,
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)
{
var requested = (fields ?? [])
.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)
{
var result = DefaultActionLists();
foreach (var field in result.Keys.ToArray())
{
var raw = actions is not null && actions.TryGetValue(field, out var configured)
? configured
: [JellyfinAction];
result[field] = NormalizeActionList(field, raw, allowedLanguages);
}
return result;
}
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(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? raw, IReadOnlySet<string> allowedLanguages)
{
var action = (raw ?? string.Empty).Trim();
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()
: string.Empty;
var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical;
}
}
@@ -1,4 +1,3 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using Jellyfin.Plugin.Multilang.Data;
@@ -18,8 +17,6 @@ public sealed class AssetStorageService
public async Task<string?> StoreAsync(
string itemId,
string lang,
string kind,
string sourceUrl,
bool localStorage,
CancellationToken cancellationToken)
@@ -30,22 +27,17 @@ public sealed class AssetStorageService
if (!localStorage)
return sourceUrl;
var relative = BuildRelativePath(itemId, lang, kind, sourceUrl);
var relative = BuildRelativePath(itemId, sourceUrl);
var expectedUrl = TranslationStore.LocalAssetUrlPrefix + relative.Replace('\\', '/');
if (_store.GetAssetPath(itemId, lang, kind) is { } existing &&
existing.Equals(expectedUrl, StringComparison.OrdinalIgnoreCase) &&
_store.TryNormalizeLocalAssetPath(existing, out var existingPath) &&
File.Exists(existingPath))
{
return existing;
}
var destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative));
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Resolved asset path escaped the Multilang assets directory.");
Directory.CreateDirectory(Path.GetDirectoryName(destination) ?? _store.AssetsDirectory);
if (File.Exists(destination))
return expectedUrl;
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
await DownloadAsync(sourceUrl, destination, cancellationToken).ConfigureAwait(false);
return expectedUrl;
}
@@ -101,11 +93,11 @@ public sealed class AssetStorageService
File.Move(temp, destination, overwrite: true);
}
private static string BuildRelativePath(string itemId, string lang, string kind, string sourceUrl)
private static string BuildRelativePath(string itemId, string sourceUrl)
{
var extension = ExtensionFromUrl(sourceUrl);
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sourceUrl))).ToLowerInvariant()[..16];
return Path.Combine(SafeSegment(itemId), SafeSegment(lang), SafeSegment(kind) + "-" + hash + extension);
return Path.Combine(SafeSegment(itemId), hash + extension);
}
private static string SafeSegment(string value)
@@ -133,6 +125,6 @@ public sealed class AssetStorageService
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
_ => MediaTypeHeaderValue.Parse("application/octet-stream").MediaType ?? "application/octet-stream"
_ => "application/octet-stream"
};
}
@@ -1,3 +1,4 @@
using Jellyfin.Plugin.Multilang.Rules;
using System.Globalization;
using System.IO.Compression;
using System.Text.Json;
@@ -142,6 +143,14 @@ public sealed class MultilangBackupService
using var zip = ZipFile.OpenRead(zipPath);
ValidateManifest(zip);
var dbPath = Path.Combine(tempDir, DatabaseName);
var dbEntry = options.TranslationsDatabase ? zip.GetEntry(DatabaseName) : null;
if (dbEntry is not null)
{
dbEntry.ExtractToFile(dbPath);
TranslationStore.ValidateTranslationBackup(dbPath);
}
var importedConfig = false;
var importedUsers = 0;
var ignoredUsers = 0;
@@ -173,21 +182,13 @@ public sealed class MultilangBackupService
continue;
}
_store.SaveUserRules(userId, payload.Rules);
_store.SaveUserRules(userId, UserRulesNormalizer.Normalize(payload.Rules, currentConfiguration.Languages));
importedUsers++;
}
}
if (options.TranslationsDatabase)
{
var dbEntry = zip.GetEntry(DatabaseName);
if (dbEntry is not null)
{
var dbPath = Path.Combine(tempDir, DatabaseName);
dbEntry.ExtractToFile(dbPath, overwrite: true);
database = _store.ImportTranslationDatabase(dbPath, GetLiveItemIds(), GetAllowedLanguages(currentConfiguration));
}
}
if (options.DownloadedAssets)
{
@@ -268,7 +269,7 @@ public sealed class MultilangBackupService
if (payload?.Rules is null)
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, [], false);
_store.SaveUserRules(current, payload.Rules);
_store.SaveUserRules(current, UserRulesNormalizer.Normalize(payload.Rules, Plugin.Instance?.Configuration.Languages));
return new BackupImportResult(false, 1, 0, 0, 0, 0, 0, 0, 0, [], false);
}
finally
@@ -10,7 +10,7 @@ public sealed class ItemsProxyCache
public required string Url { get; init; }
public required HashSet<string> ItemIds { get; init; }
public required int ItemCount { get; init; }
public required string Body { get; init; }
@@ -21,6 +21,7 @@ public sealed class ItemsProxyCache
public required long DurationMs { get; init; }
public required long SizeBytes { get; init; }
public bool Micro { get; init; }
}
private sealed class RecentRequest
@@ -33,6 +34,8 @@ public sealed class ItemsProxyCache
public required bool CacheStored { get; init; }
public required bool InFlightCoalesced { get; init; }
public required int StatusCode { get; init; }
public required int ItemCount { get; init; }
@@ -48,50 +51,44 @@ public sealed class ItemsProxyCache
private readonly object _lock = new();
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly Dictionary<string, Entry> _microEntries = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _userPolicies = new(StringComparer.Ordinal);
private readonly Queue<RecentRequest> _recentRequests = new();
private long _totalBytes;
private long _generation;
public long Generation { get { lock (_lock) return _generation; } }
private const int MaxRecentRequests = 100;
private const int MaxMicroEntries = 50;
private static readonly TimeSpan MicroTtl = TimeSpan.FromSeconds(3);
public long ObserveUserPolicy(string userId, string policy)
{
lock (_lock)
{
if (_userPolicies.TryGetValue(userId, out var previous) && previous != policy)
ClearUser(userId);
_userPolicies[userId] = policy;
return _generation;
}
}
public void ClearAll()
{
lock (_lock)
{
_entries.Clear();
_microEntries.Clear();
_totalBytes = 0;
_generation++;
}
}
public void InvalidateItems(IEnumerable<string> itemIds)
public void ClearUser(string userId)
{
var ids = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (ids.Count == 0)
return;
lock (_lock)
{
foreach (var key in _entries.Values
.Where(e => e.ItemIds.Overlaps(ids))
.Select(e => e.Key)
.ToArray())
{
_generation++;
foreach (var key in _entries.Values.Where(e => e.UserId == userId).Select(e => e.Key).ToArray())
Remove(key);
}
foreach (var key in _microEntries.Values
.Where(e => e.ItemIds.Overlaps(ids))
.Select(e => e.Key)
.ToArray())
{
_microEntries.Remove(key);
}
}
}
public bool TryGet(string key, TimeSpan ttl, out CachedItemsProxyResponse response)
@@ -100,24 +97,17 @@ public sealed class ItemsProxyCache
{
PurgeExpired(ttl);
if (!_entries.TryGetValue(key, out var entry))
{
PurgeExpired(_microEntries, MicroTtl);
if (!_microEntries.TryGetValue(key, out var microEntry))
{
response = default;
return false;
}
response = new CachedItemsProxyResponse(microEntry.Body, microEntry.ContentType, microEntry.ItemIds.Count, microEntry.SizeBytes);
return true;
}
response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemIds.Count, entry.SizeBytes);
response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemCount, entry.SizeBytes);
return true;
}
}
public bool Store(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes, TimeSpan ttl)
public bool Store(string key, string userId, string url, int itemCount, string body, string contentType, long durationMs, long maxBytes, TimeSpan ttl, long? generation = null, bool micro = false)
{
if (maxBytes <= 0 || ttl <= TimeSpan.Zero)
return false;
@@ -128,76 +118,40 @@ public sealed class ItemsProxyCache
lock (_lock)
{
if (generation.HasValue && generation != _generation)
return false;
PurgeExpired(ttl);
Remove(key);
if (micro && _entries.Values.Count(e => e.Micro) >= MaxMicroEntries)
Remove(_entries.Values.Where(e => e.Micro).MinBy(e => e.CreatedUtc)!.Key);
while (_totalBytes + sizeBytes > maxBytes && _entries.Count > 0)
{
var victim = _entries.Values.OrderBy(e => e.CreatedUtc).First();
Remove(victim.Key);
}
if (_totalBytes + sizeBytes > maxBytes)
return false;
_entries[key] = new Entry
{
Key = key,
UserId = userId,
Url = url,
ItemIds = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase),
ItemCount = itemCount,
Body = body,
ContentType = contentType,
CreatedUtc = DateTimeOffset.UtcNow,
DurationMs = durationMs,
SizeBytes = sizeBytes
SizeBytes = sizeBytes,
Micro = micro
};
_totalBytes += sizeBytes;
return true;
}
}
public bool StoreMicro(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes)
{
if (maxBytes <= 0)
return false;
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
if (sizeBytes > maxBytes)
return false;
lock (_lock)
{
PurgeExpired(_microEntries, MicroTtl);
_microEntries.Remove(key);
while (_microEntries.Count >= MaxMicroEntries)
{
var victim = _microEntries.Values.OrderBy(e => e.CreatedUtc).First();
_microEntries.Remove(victim.Key);
}
_microEntries[key] = new Entry
{
Key = key,
UserId = userId,
Url = url,
ItemIds = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase),
Body = body,
ContentType = contentType,
CreatedUtc = DateTimeOffset.UtcNow,
DurationMs = durationMs,
SizeBytes = sizeBytes
};
return true;
}
}
public bool StoreMicro(string key, string userId, string url, int itemCount, string body, string contentType, long durationMs, long maxBytes, long? generation = null, TimeSpan? ttl = null)
=> Store(key, userId, url, itemCount, body, contentType, durationMs, maxBytes, ttl ?? TimeSpan.FromMinutes(1), generation, micro: true);
public object[] GetEntries()
{
@@ -205,6 +159,7 @@ public sealed class ItemsProxyCache
{
var now = DateTimeOffset.UtcNow;
return _entries.Values
.Where(e => !e.Micro)
.OrderByDescending(e => e.CreatedUtc)
.Select(e => new
{
@@ -213,7 +168,7 @@ public sealed class ItemsProxyCache
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.DurationMs,
e.SizeBytes,
ItemCount = e.ItemIds.Count
ItemCount = e.ItemCount
})
.Cast<object>()
.ToArray();
@@ -229,7 +184,8 @@ public sealed class ItemsProxyCache
long totalMs,
long upstreamMs,
long transformMs,
long sizeBytes)
long sizeBytes,
bool inFlightCoalesced = false)
{
lock (_lock)
{
@@ -239,6 +195,7 @@ public sealed class ItemsProxyCache
CreatedUtc = DateTimeOffset.UtcNow,
CacheHit = cacheHit,
CacheStored = cacheStored,
InFlightCoalesced = inFlightCoalesced,
StatusCode = statusCode,
ItemCount = itemCount,
TotalMs = totalMs,
@@ -265,6 +222,7 @@ public sealed class ItemsProxyCache
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.CacheHit,
e.CacheStored,
e.InFlightCoalesced,
e.StatusCode,
e.ItemCount,
e.TotalMs,
@@ -283,26 +241,16 @@ public sealed class ItemsProxyCache
return;
var now = DateTimeOffset.UtcNow;
foreach (var key in _entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray())
foreach (var key in _entries.Values.Where(e => now - e.CreatedUtc > (e.Micro ? MicroTtl : ttl)).Select(e => e.Key).ToArray())
Remove(key);
}
private static void PurgeExpired(Dictionary<string, Entry> entries, TimeSpan ttl)
{
if (ttl <= TimeSpan.Zero || entries.Count == 0)
return;
var now = DateTimeOffset.UtcNow;
foreach (var key in entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray())
entries.Remove(key);
}
private void Remove(string key)
{
if (!_entries.Remove(key, out var existing))
return;
_totalBytes = Math.Max(0, _totalBytes - existing.SizeBytes);
_totalBytes -= existing.SizeBytes;
}
}
@@ -0,0 +1,35 @@
using Jellyfin.Data.Events;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
namespace Jellyfin.Plugin.Multilang.Services;
public sealed class ItemsProxyInvalidationService(
ILibraryManager library, IUserManager users, IUserDataManager userData, ItemsProxyCache cache) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
library.ItemAdded += LibraryChanged;
library.ItemUpdated += LibraryChanged;
library.ItemRemoved += LibraryChanged;
users.OnUserUpdated += UserChanged;
userData.UserDataSaved += UserDataChanged;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
library.ItemAdded -= LibraryChanged;
library.ItemUpdated -= LibraryChanged;
library.ItemRemoved -= LibraryChanged;
users.OnUserUpdated -= UserChanged;
userData.UserDataSaved -= UserDataChanged;
return Task.CompletedTask;
}
// Membership and native filters can change even when an item wasn't in a cached result.
private void LibraryChanged(object? sender, ItemChangeEventArgs args) => cache.ClearAll();
private void UserChanged(object? sender, GenericEventArgs<User> args) => cache.ClearUser(args.Argument.Id.ToString("N"));
private void UserDataChanged(object? sender, UserDataSaveEventArgs args) => cache.ClearUser(args.UserId.ToString("N"));
}
@@ -0,0 +1,123 @@
using System.Text.Json.Nodes;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.Services;
public sealed class ItemsProxyPrecacheService
{
private static readonly TimeSpan IdleThreshold = TimeSpan.FromMinutes(30);
private readonly IServerApplicationHost _applicationHost;
private readonly IConfigurationManager _configurationManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ItemsProxyPrecacheService> _logger;
private readonly object _lock = new();
private readonly Dictionary<string, DateTimeOffset> _lastSeen = new(StringComparer.Ordinal);
private readonly HashSet<string> _pendingUsers = new(StringComparer.Ordinal);
public ItemsProxyPrecacheService(
IServerApplicationHost applicationHost,
IConfigurationManager configurationManager,
IHttpClientFactory httpClientFactory,
ILogger<ItemsProxyPrecacheService> logger)
{
_applicationHost = applicationHost;
_configurationManager = configurationManager;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public void ObserveUserActivity(
string userId,
string token,
string clientLocale,
bool precacheMovieLibraries,
bool precacheTvShowLibraries)
{
if (!precacheMovieLibraries && !precacheTvShowLibraries)
return;
var now = DateTimeOffset.UtcNow;
lock (_lock)
{
var wasRecentlyActive = _lastSeen.TryGetValue(userId, out var lastSeen) && now - lastSeen < IdleThreshold;
_lastSeen[userId] = now;
if (wasRecentlyActive || !_pendingUsers.Add(userId))
return;
}
_ = PrecacheAsync(userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries);
}
private async Task PrecacheAsync(
string userId,
string token,
string clientLocale,
bool precacheMovieLibraries,
bool precacheTvShowLibraries)
{
try
{
var baseUri = LocalApiUri;
var views = await GetJsonAsync(new Uri(baseUri, $"Users/{userId}/Views"), token).ConfigureAwait(false);
var items = views["Items"]?.AsArray() ?? [];
var targets = items
.Select(view => new
{
Id = view?["Id"]?.GetValue<string>() ?? string.Empty,
CollectionType = view?["CollectionType"]?.GetValue<string>() ?? string.Empty
})
.Where(view => !string.IsNullOrWhiteSpace(view.Id))
.Where(view =>
(precacheMovieLibraries && view.CollectionType.Equals("movies", StringComparison.OrdinalIgnoreCase)) ||
(precacheTvShowLibraries && view.CollectionType.Equals("tvshows", StringComparison.OrdinalIgnoreCase)))
.ToArray();
foreach (var target in targets)
{
var itemType = target.CollectionType.Equals("movies", StringComparison.OrdinalIgnoreCase) ? "Movie" : "Series";
var itemsUrl = $"/Users/{userId}/Items?ParentId={Uri.EscapeDataString(target.Id)}&IncludeItemTypes={itemType}&Recursive=true&SortBy=SortName&SortOrder=Ascending&StartIndex=0&Limit=100";
var endpoint = "Multilang/ItemsProxy?url=" + Uri.EscapeDataString(itemsUrl) + "&precache=true";
if (!string.IsNullOrWhiteSpace(clientLocale))
endpoint += "&mlLocale=" + Uri.EscapeDataString(clientLocale);
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, endpoint));
AddTokenHeaders(request, token);
using var response = await _httpClientFactory.CreateClient("Multilang.Jellyfin").SendAsync(request, CancellationToken.None).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
_logger.LogWarning("Multilang pre-cache failed user={UserId} library={LibraryId} status={StatusCode}", userId, target.Id, (int)response.StatusCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Multilang pre-cache failed for user={UserId}", userId);
}
finally
{
lock (_lock)
_pendingUsers.Remove(userId);
}
}
private async Task<JsonObject> GetJsonAsync(Uri uri, string token)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
AddTokenHeaders(request, token);
using var response = await _httpClientFactory.CreateClient("Multilang.Jellyfin").SendAsync(request, CancellationToken.None).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(CancellationToken.None).ConfigureAwait(false);
return JsonNode.Parse(body)?.AsObject() ?? throw new InvalidOperationException("Jellyfin views response was not a JSON object.");
}
private static void AddTokenHeaders(HttpRequestMessage request, string token)
{
request.Headers.TryAddWithoutValidation("Authorization", $"MediaBrowser Token=\"{token}\"");
request.Headers.TryAddWithoutValidation("X-Emby-Token", token);
request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token);
}
public Uri LocalApiUri => new(_applicationHost.GetLocalApiUrl("127.0.0.1", "http",
_configurationManager.GetNetworkConfiguration().InternalHttpPort).TrimEnd('/') + "/");
}
@@ -15,28 +15,23 @@ public sealed record ItemsProxyRequest(
public static class ItemsProxyRequestBuilder
{
public static ItemsProxyRequest? Build(HttpRequest request, string rawUrl, string token, string userId, bool multilangEnabled)
private const string BrowseFields = "PrimaryImageAspectRatio,MediaSourceCount,SortName,Overview,Genres,GenreItems";
public static ItemsProxyRequest? Build(HttpRequest request, string rawUrl, string userId, bool multilangEnabled)
{
var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}";
var baseAddress = new Uri(baseUri);
var uri = new Uri(baseAddress, rawUrl.StartsWith('/') ? rawUrl : "/" + rawUrl);
if ((rawUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) &&
Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute))
{
if (!absolute.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!absolute.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
var baseAddress = new Uri($"{request.Scheme}://{request.Host}{request.PathBase}/");
if (rawUrl.Contains('\\') || !Uri.TryCreate(baseAddress, rawUrl, out var uri) ||
uri.Scheme != baseAddress.Scheme || uri.Authority != baseAddress.Authority ||
uri.UserInfo.Length > 0 || uri.Fragment.Length > 0)
return null;
if (!absolute.Host.Equals(baseAddress.Host, StringComparison.OrdinalIgnoreCase) ||
absolute.Port != baseAddress.Port)
var pathBase = request.PathBase.Value ?? string.Empty;
if (!uri.AbsolutePath.StartsWith(pathBase + "/", StringComparison.OrdinalIgnoreCase))
return null;
uri = absolute;
}
var path = NormalizeUpstreamPath(uri.AbsolutePath, userId);
var builder = new UriBuilder(uri) { Path = path };
var route = uri.AbsolutePath[pathBase.Length..];
if (!IsSupportedRoute(route))
return null;
var path = NormalizeUpstreamPath(route, userId);
var builder = new UriBuilder(uri) { Path = pathBase + path };
var query = QueryHelpers.ParseQuery(builder.Query);
var rawControls = new ItemsProxyControls(
GetQueryString(query, "SortBy"),
@@ -48,36 +43,25 @@ public static class ItemsProxyRequestBuilder
GetQueryString(query, "GenreIds"));
var hasLocalGenreFilter = multilangEnabled && ParseLocalGenreIds(rawControls.GenreIds).Length > 0;
var useLocalControls = multilangEnabled && ShouldUseLocalControls(path, query, hasLocalGenreFilter);
if (useLocalControls)
query["Fields"] = string.Join(',', (BrowseFields + "," + GetQueryString(query, "Fields"))
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(field => field, StringComparer.OrdinalIgnoreCase));
var controls = useLocalControls
? rawControls
: new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds);
var localCacheKey = string.Join('|',
controls.SortBy,
controls.SortOrder,
controls.NameStartsWith,
controls.StartIndex.ToString(CultureInfo.InvariantCulture),
controls.Limit.ToString(CultureInfo.InvariantCulture),
controls.ClientLocale,
controls.GenreIds);
: new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds, ApplyLocalPaging: false);
var pairs = query
.Where(kv => !IsLocalProxyQuery(kv.Key, hasLocalGenreFilter, useLocalControls))
.SelectMany(kv => kv.Value.Select(v => KeyValuePair.Create<string, string?>(kv.Key, v ?? string.Empty)))
.ToList();
pairs.Add(KeyValuePair.Create<string, string?>("userId", userId));
if (hasLocalGenreFilter)
{
pairs.Add(KeyValuePair.Create<string, string?>("EnableImageTypes", "Primary,Backdrop,Banner,Thumb"));
pairs.Add(KeyValuePair.Create<string, string?>("Fields", "Genres,GenreItems"));
}
var cacheQuery = QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?');
pairs.Add(KeyValuePair.Create<string, string?>("api_key", token));
builder.Query = QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?');
var cacheQuery = QueryHelpers.AddQueryString(string.Empty, pairs.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)).TrimStart('?');
builder.Query = cacheQuery;
var normalizedForCache = builder.Path + (cacheQuery.Length > 0 ? "?" + cacheQuery : string.Empty);
return new ItemsProxyRequest(
builder.Uri,
controls,
$"{userId}|{normalizedForCache}|{localCacheKey}",
$"{userId}|{normalizedForCache}",
normalizedForCache,
IsGenresPath(path),
GenreMediaFromQuery(query));
@@ -92,6 +76,22 @@ public static class ItemsProxyRequestBuilder
.Distinct()
.ToArray();
private static bool IsSupportedRoute(string path)
{
var parts = path.ToLowerInvariant().Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3 && parts[0] == "users" && Guid.TryParse(parts[1], out _))
parts = parts[2..];
return parts switch
{
["items"] or ["genres"] => true,
["items", "latest" or "resume" or "nextup"] => true,
["items" or "genres", var id] => Guid.TryParse(id, out _),
["shows", "nextup"] => true,
["shows", var id, "seasons" or "episodes"] => Guid.TryParse(id, out _),
_ => false
};
}
private static bool ShouldUseLocalControls(
string normalizedPath,
IReadOnlyDictionary<string, StringValues> query,
@@ -136,17 +136,14 @@ public static class ItemsProxyRequestBuilder
key.Equals("Limit", StringComparison.OrdinalIgnoreCase) ||
key.Equals("NameStartsWith", StringComparison.OrdinalIgnoreCase))) ||
(hasGenreFilter && (key.Equals("GenreIds", StringComparison.OrdinalIgnoreCase) ||
key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase) ||
key.Equals("EnableImageTypes", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Fields", StringComparison.OrdinalIgnoreCase)));
key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase)));
private static string NormalizeUpstreamPath(string path, string userId)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
if (segments.Count >= 2 &&
segments[0].Equals("Users", StringComparison.OrdinalIgnoreCase) &&
segments[1].Length == 32 &&
segments[1].All(Uri.IsHexDigit))
Guid.TryParse(segments[1], out _))
{
segments[1] = userId;
return "/" + string.Join('/', segments);
@@ -0,0 +1,68 @@
namespace Jellyfin.Plugin.Multilang.Services;
public readonly record struct ItemsProxyUpstreamResponse(int StatusCode, string Body, string ContentType)
{
public bool IsSuccessStatusCode => StatusCode is >= 200 and < 300;
}
public readonly record struct CoalescedItemsProxyResponse(ItemsProxyUpstreamResponse Response, bool JoinedExistingRequest);
public sealed class ItemsProxyRequestCoalescer
{
private sealed class InFlightRequest
{
public TaskCompletionSource<ItemsProxyUpstreamResponse> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
private readonly object _lock = new();
private readonly Dictionary<string, InFlightRequest> _requests = new(StringComparer.Ordinal);
public async Task<CoalescedItemsProxyResponse> GetOrFetchAsync(
string key,
Func<CancellationToken, Task<ItemsProxyUpstreamResponse>> fetch,
CancellationToken cancellationToken)
{
InFlightRequest request;
var joinedExistingRequest = true;
lock (_lock)
{
if (_requests.TryGetValue(key, out var existing) && existing is not null)
{
request = existing;
}
else
{
request = new InFlightRequest();
_requests.Add(key, request);
joinedExistingRequest = false;
_ = CompleteAsync(key, request, fetch);
}
}
var response = await request.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
return new CoalescedItemsProxyResponse(response, joinedExistingRequest);
}
private async Task CompleteAsync(
string key,
InFlightRequest request,
Func<CancellationToken, Task<ItemsProxyUpstreamResponse>> fetch)
{
try
{
request.Completion.TrySetResult(await fetch(CancellationToken.None).ConfigureAwait(false));
}
catch (Exception ex)
{
request.Completion.TrySetException(ex);
}
finally
{
lock (_lock)
{
if (_requests.TryGetValue(key, out var current) && ReferenceEquals(current, request))
_requests.Remove(key);
}
}
}
}
@@ -12,7 +12,8 @@ public sealed record ItemsProxyControls(
int StartIndex,
int Limit,
string ClientLocale,
string GenreIds);
string GenreIds,
bool ApplyLocalPaging = true);
public static class ItemsProxySorting
{
@@ -29,16 +30,21 @@ public static class ItemsProxySorting
}
public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles)
=> Apply(root, controls, culture, _ => titleArticles);
public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, Func<JsonObject, string[]> titleArticlesForItem)
{
if (!controls.ApplyLocalPaging)
return;
if (!TryGetItemsArray(root, out var itemsArray))
return;
var items = itemsArray.OfType<JsonObject>().ToList();
var specs = BuildSortSpecs(controls.SortBy);
if (specs.Length > 0 && items.Count > 1)
items = Sort(items, specs, controls.SortOrder, culture, titleArticles);
items = Sort(items, specs, controls.SortOrder, culture, titleArticlesForItem);
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticles);
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticlesForItem);
var filteredCount = items.Count;
items = Slice(items, controls.StartIndex, controls.Limit);
@@ -77,24 +83,44 @@ public static class ItemsProxySorting
}
}
public static string[] GetSortArticles(SortArticleCatalog articleCatalog, IEnumerable<SortArticleEntry> configuredArticles, string? sortLocale)
public static string[] GetSortArticles(
SortArticleCatalog articleCatalog,
IReadOnlyList<SortArticleEntry> configuredArticles,
string? titleLanguage,
bool ignoreArticles = true)
{
var locale = (sortLocale ?? string.Empty).Trim();
if (locale.Length == 0)
locale = "en";
if (!ignoreArticles)
return [];
var configured = configuredArticles.ToArray();
var custom = configured.FirstOrDefault(e => e.Language.Equals(locale, StringComparison.OrdinalIgnoreCase))
?? configured.FirstOrDefault(e => locale.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase));
var always = configuredArticles
.Where(e => e.AlwaysApply == true)
.SelectMany(e => SplitArticles(e.Articles));
return always
.Concat(GetLanguageArticles(articleCatalog, configuredArticles, titleLanguage))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string[] GetLanguageArticles(
SortArticleCatalog articleCatalog,
IReadOnlyList<SortArticleEntry> configured,
string? titleLanguage)
{
var language = (titleLanguage ?? string.Empty).Trim();
if (language.Length == 0)
return [];
var custom = configured.FirstOrDefault(e => e.Language.Equals(language, StringComparison.OrdinalIgnoreCase))
?? configured.FirstOrDefault(e => language.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase));
if (custom is not null)
return SplitArticles(custom.Articles);
var builtIns = articleCatalog.GetBuiltIns();
if (builtIns.TryGetValue(locale, out var exact))
if (builtIns.TryGetValue(language, out var exact))
return exact;
var dash = locale.IndexOf('-', StringComparison.Ordinal);
return dash > 0 && builtIns.TryGetValue(locale[..dash], out var languageOnly)
var dash = language.IndexOf('-', StringComparison.Ordinal);
return dash > 0 && builtIns.TryGetValue(language[..dash], out var languageOnly)
? languageOnly
: [];
}
@@ -149,12 +175,12 @@ public static class ItemsProxySorting
_ => SortKind.Text
};
private static List<JsonObject> Sort(List<JsonObject> items, SortSpec[] specs, string sortOrder, CultureInfo culture, string[] titleArticles)
private static List<JsonObject> Sort(List<JsonObject> items, SortSpec[] specs, string sortOrder, CultureInfo culture, Func<JsonObject, string[]> titleArticlesForItem)
{
var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true;
var comparer = StringComparer.Create(culture, true);
var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticles)).ToArray() }).ToList();
var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticlesForItem(item))).ToArray() }).ToList();
values.Sort((left, right) =>
{
@@ -197,14 +223,14 @@ public static class ItemsProxySorting
private readonly record struct SortValue(bool HasValue, string Text, double Number);
private static List<JsonObject> FilterByNameStartsWith(List<JsonObject> items, string prefix, CultureInfo culture, string[] titleArticles)
private static List<JsonObject> FilterByNameStartsWith(List<JsonObject> items, string prefix, CultureInfo culture, Func<JsonObject, string[]> titleArticlesForItem)
{
prefix = (prefix ?? string.Empty).Trim();
if (prefix.Length == 0)
return items;
return items
.Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticles), prefix, CompareOptions.IgnoreCase))
.Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticlesForItem(i)), prefix, CompareOptions.IgnoreCase))
.ToList();
}
@@ -227,8 +253,9 @@ public static class ItemsProxySorting
var word = article.Trim();
if (word.Length == 0 || text.Length <= word.Length)
continue;
if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase))
return text[(word.Length + 1)..].TrimStart();
var attachedArticle = word.EndsWith("'", StringComparison.Ordinal);
if (text.StartsWith(word + (attachedArticle ? string.Empty : " "), StringComparison.OrdinalIgnoreCase))
return text[word.Length..].TrimStart();
}
return text;
@@ -13,7 +13,7 @@ namespace Jellyfin.Plugin.Multilang.Services;
public readonly record struct ItemsProxyTransformResult(string Body, string[] ItemIds);
public readonly record struct ItemsProxyResolvedText(bool Change, string? Value);
public readonly record struct ItemsProxyResolvedText(bool Change, string? Value, string? Language = null);
public readonly record struct ItemsProxyResolvedAsset(bool Change, string? Value);
@@ -22,6 +22,9 @@ public readonly record struct ItemsProxyCategoryMatch(
UserCategoryRule? Effective,
bool DuplicateLabelResolved);
public sealed record ItemsProxyResolutionAttempt(
string Action, string Source, string LookupKey, bool HasValue, bool Chosen, string? Value, string Reason);
public sealed class ItemsProxyTransformer
{
private readonly TranslationStore _store;
@@ -61,9 +64,9 @@ public sealed class ItemsProxyTransformer
if (ids.Length == 0)
return new ItemsProxyTransformResult(body, ids);
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale);
var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale);
var sortArticles = ItemsProxySorting.GetSortArticles(_articleCatalog, Plugin.Instance?.Configuration?.ArticleEntries ?? [], sortLocale);
var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : [];
if (rules.Enabled || localGenreIds.Length > 0)
@@ -80,10 +83,26 @@ public sealed class ItemsProxyTransformer
ids = GetItemIds(items);
}
if (rules.Enabled)
ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem);
var titleLanguages = rules.Enabled
? ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem)
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var jellyfinTitleLanguages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var sortArticlesByLanguage = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
ItemsProxySorting.Apply(root, controls, sortCulture, sortArticles);
ItemsProxySorting.Apply(root, controls, sortCulture, item =>
{
var itemId = TryGetItemId(item);
var language = itemId is not null && titleLanguages.TryGetValue(itemId, out var translatedLanguage)
? translatedLanguage
: ResolveJellyfinTitleLanguage(itemId, config, jellyfinTitleLanguages);
if (!sortArticlesByLanguage.TryGetValue(language, out var articles))
{
articles = ItemsProxySorting.GetSortArticles(_articleCatalog, config.ArticleEntries, language, config.IgnoreArticlesWhenSorting);
sortArticlesByLanguage[language] = articles;
}
return articles;
});
return new ItemsProxyTransformResult(root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), ids);
}
@@ -141,8 +160,6 @@ public sealed class ItemsProxyTransformer
{
foreach (var action in GetAllActionTokens(category.FieldActionLists))
AddLanguageAction(langs, action);
foreach (var action in (category.FieldActions ?? []).Values)
AddLanguageAction(langs, action);
}
foreach (var action in GetAllActionTokens(rules.FallbackFieldActions))
@@ -168,29 +185,28 @@ public sealed class ItemsProxyTransformer
}
public FactsData GetClassificationFacts(UserRulesDocument rules, FactsData facts)
=> GetClassificationFacts(rules, new Dictionary<string, FactsData> { [facts.ItemId] = facts })[facts.ItemId];
private IReadOnlyDictionary<string, FactsData> GetClassificationFacts(UserRulesDocument rules, IReadOnlyDictionary<string, FactsData> facts)
{
if (rules.TrustTmdbCollections ||
!facts.Kind.Equals("collection", StringComparison.OrdinalIgnoreCase) ||
!Guid.TryParseExact(facts.ItemId, "N", out var itemGuid))
{
if (rules.TrustTmdbCollections)
return facts;
var children = facts.Values.Where(fact => fact.Kind == "collection")
.ToDictionary(fact => fact.ItemId, fact =>
(_libraryManager.GetItemById(Guid.Parse(fact.ItemId)) as Folder)?.GetLinkedChildren()
.OfType<MediaBrowser.Controller.Entities.Movies.Movie>()
.Select(child => TranslationStore.ToItemId32(child.Id)).ToArray() ?? []);
var childFacts = _store.GetFactsForItems(children.Values.SelectMany(ids => ids));
var result = new Dictionary<string, FactsData>(facts, StringComparer.OrdinalIgnoreCase);
foreach (var (id, ids) in children)
result[id] = AggregateCollectionFacts(facts[id], ids.Where(childFacts.ContainsKey).Select(id => childFacts[id]).ToArray());
return result;
}
var item = _libraryManager.GetItemById(itemGuid);
var children = (item as Folder)?.GetLinkedChildren()
.Where(child => child.GetType().Name.Equals("Movie", StringComparison.OrdinalIgnoreCase))
.ToArray() ?? [];
var childIds = children.Select(child => TranslationStore.ToItemId32(child.Id)).ToArray();
if (childIds.Length == 0)
return facts;
var childFacts = _store.GetFactsForItems(childIds).Values
.Where(child => child.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase))
.ToArray();
public static FactsData AggregateCollectionFacts(FactsData facts, FactsData[] childFacts)
{
if (childFacts.Length == 0)
return facts;
return facts with
{
OriginalLanguage = SharedSingle(childFacts.Select(child => child.OriginalLanguage)),
@@ -212,74 +228,61 @@ public sealed class ItemsProxyTransformer
if (actions is not null && actions.TryGetValue(field, out var list))
return list;
return category is not null && category.FieldActions is not null
? LegacyActionToList(field, category.FieldActions)
: [JellyfinAction];
return [JellyfinAction];
}
public static ItemsProxyResolvedText ResolveField(
string field,
IReadOnlyList<string> actions,
FactsData facts,
Dictionary<string, Dictionary<string, string>>? byLang)
string field, IReadOnlyList<string> actions, FactsData facts,
Dictionary<string, Dictionary<string, string>>? byLang,
Action<ItemsProxyResolutionAttempt>? trace = null)
{
foreach (var action in actions)
{
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
return new ItemsProxyResolvedText(false, null);
if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) &&
action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(facts.OriginalTitle))
return new ItemsProxyResolvedText(true, facts.OriginalTitle);
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
{
var originalValue = GetTranslatedField(byLang, OriginalAction, field);
if (!string.IsNullOrWhiteSpace(originalValue))
return new ItemsProxyResolvedText(true, originalValue);
continue;
trace?.Invoke(new(action, JellyfinAction, "", true, true, null, "Use Jellyfin value"));
return new(false, null);
}
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
var original = action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase);
if (!original && !action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
continue;
var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field);
if (!string.IsNullOrWhiteSpace(value))
return new ItemsProxyResolvedText(true, value);
var lang = original ? OriginalAction : action[LanguagePrefix.Length..];
var originalTitle = original && field == TitleField && !string.IsNullOrWhiteSpace(facts.OriginalTitle);
var value = originalTitle ? facts.OriginalTitle : GetTranslatedField(byLang, lang, field);
var found = !string.IsNullOrWhiteSpace(value);
trace?.Invoke(new(action, originalTitle ? "OriginalTitle" : "Translation",
originalTitle ? "facts.original_title" : lang + "/" + field,
found, found, value, found ? "Value found" : "Value missing or empty"));
if (found)
return new(true, value, original ? facts.OriginalLanguage : lang);
}
return field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
? new ItemsProxyResolvedText(false, null)
: new ItemsProxyResolvedText(true, string.Empty);
return field == TitleField ? new(false, null) : new(true, string.Empty);
}
public static ItemsProxyResolvedAsset ResolveAsset(
string kind,
IReadOnlyList<string> actions,
Dictionary<string, Dictionary<string, string>>? byKind)
string kind, IReadOnlyList<string> actions,
Dictionary<string, Dictionary<string, string>>? byKind,
Action<ItemsProxyResolutionAttempt>? trace = null)
{
foreach (var action in actions)
{
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
return new ItemsProxyResolvedAsset(false, null);
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
{
var originalValue = GetAsset(byKind, kind, OriginalAction);
if (!string.IsNullOrWhiteSpace(originalValue))
return new ItemsProxyResolvedAsset(true, originalValue);
continue;
trace?.Invoke(new(action, JellyfinAction, "", true, true, null, "Use Jellyfin image"));
return new(false, null);
}
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
var original = action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase);
if (!original && !action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
continue;
var value = GetAsset(byKind, kind, action[LanguagePrefix.Length..]);
if (!string.IsNullOrWhiteSpace(value))
return new ItemsProxyResolvedAsset(true, value);
var lang = original ? OriginalAction : action[LanguagePrefix.Length..];
var value = GetAsset(byKind, kind, lang);
var found = !string.IsNullOrWhiteSpace(value);
trace?.Invoke(new(action, "Asset", kind + "/" + lang, found, found, value,
found ? "Asset found" : "Asset missing"));
if (found)
return new(true, value);
}
return kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
? new ItemsProxyResolvedAsset(false, null)
: new ItemsProxyResolvedAsset(true, string.Empty);
return kind == PosterKind ? new(false, null) : new(true, string.Empty);
}
public static string? GetTranslatedField(
@@ -347,7 +350,7 @@ public sealed class ItemsProxyTransformer
return string.Empty;
}
private void ApplyRules(
private Dictionary<string, string> ApplyRules(
IEnumerable<JsonObject> items,
string[] ids,
UserRulesDocument rules,
@@ -357,6 +360,8 @@ public sealed class ItemsProxyTransformer
var langs = GetNeededLanguages(rules);
var translations = _store.GetTranslations(ids, langs);
var assets = _store.GetAssets(ids, langs);
var classification = GetClassificationFacts(rules, factsByItem);
var titleLanguages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var movieGenreNames = _store.GetGenreNames("movie", clientLocale);
var tvGenreNames = _store.GetGenreNames("tv", clientLocale);
@@ -367,12 +372,14 @@ public sealed class ItemsProxyTransformer
continue;
translations.TryGetValue(itemId, out var byLang);
var classificationFacts = GetClassificationFacts(rules, facts);
var classificationFacts = classification[itemId];
var category = PickCategoryMatch(rules, classificationFacts).Effective;
var title = ResolveField(TitleField, GetActionList(rules, category, TitleField), facts, byLang);
if (title.Change)
item["Name"] = title.Value ?? string.Empty;
if (!string.IsNullOrWhiteSpace(title.Language))
titleLanguages[itemId] = title.Language;
var overview = ResolveField(OverviewField, GetActionList(rules, category, OverviewField), facts, byLang);
if (overview.Change)
@@ -395,6 +402,27 @@ public sealed class ItemsProxyTransformer
ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind));
ApplyGenres(item, facts, movieGenreNames, tvGenreNames);
}
return titleLanguages;
}
private string ResolveJellyfinTitleLanguage(
string? itemId,
PluginConfiguration config,
Dictionary<string, string> resolvedLanguages)
{
if (string.IsNullOrWhiteSpace(itemId))
return config.JellyfinTitleLanguageFallback;
if (resolvedLanguages.TryGetValue(itemId, out var existing))
return existing;
var language = Guid.TryParse(itemId, out var itemGuid)
? _libraryManager.GetItemById(itemGuid)?.GetPreferredMetadataLanguage()
: null;
language = string.IsNullOrWhiteSpace(language) ? config.JellyfinTitleLanguageFallback : language;
resolvedLanguages[itemId] = language ?? string.Empty;
return resolvedLanguages[itemId];
}
private static void ApplyImageAsset(JsonObject item, string imageType, ItemsProxyResolvedAsset asset)
@@ -543,15 +571,6 @@ public sealed class ItemsProxyTransformer
langs.Add(action[LanguagePrefix.Length..]);
}
private static string[] LegacyActionToList(string field, IReadOnlyDictionary<string, string>? legacyActions)
{
if (legacyActions is null || !legacyActions.TryGetValue(field, out var action))
return [JellyfinAction];
if (string.IsNullOrWhiteSpace(action) || action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase))
return [JellyfinAction];
return [action];
}
private static string SharedSingle(IEnumerable<string> values)
{
var unique = values
@@ -32,8 +32,9 @@ public sealed class FanartClient
var url = $"https://webservice.fanart.tv/v3/movies/{Uri.EscapeDataString(tmdbId)}?api_key={Uri.EscapeDataString(apiKey)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await System.Text.Json.JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -6,6 +6,7 @@ public sealed class FetchRateLimiter
{
private readonly TimeSpan _minimumSpacing;
private long _lastStartTicks;
private readonly SemaphoreSlim _admission = new(1, 1);
public FetchRateLimiter(TimeSpan minimumSpacing)
{
@@ -14,15 +15,18 @@ public sealed class FetchRateLimiter
public async Task WaitAsync(CancellationToken cancellationToken)
{
var last = Interlocked.Read(ref _lastStartTicks);
if (last != 0)
await _admission.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var elapsed = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - last) / (double)Stopwatch.Frequency);
var remaining = _minimumSpacing - elapsed;
if (_lastStartTicks != 0)
{
var remaining = _minimumSpacing - Stopwatch.GetElapsedTime(_lastStartTicks);
if (remaining > TimeSpan.Zero)
await Task.Delay(remaining, cancellationToken).ConfigureAwait(false);
}
Interlocked.Exchange(ref _lastStartTicks, Stopwatch.GetTimestamp());
_lastStartTicks = Stopwatch.GetTimestamp();
}
finally { _admission.Release(); }
}
}
@@ -39,29 +39,33 @@ public static class ProviderHttp
public static async Task<HttpResponseMessage> SendWithRetryAsync(
HttpClient http,
HttpRequestMessage request,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
FetchRateLimiter? rateLimiter = null)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var response = await http.SendAsync(Clone(request), cancellationToken).ConfigureAwait(false);
if (rateLimiter is not null)
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
using var attemptRequest = Clone(request);
var response = await http.SendAsync(attemptRequest, cancellationToken).ConfigureAwait(false);
Record(request, "HTTP " + (int)response.StatusCode, attempt);
if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts)
{
Record(request, "HTTP " + (int)response.StatusCode, attempt);
return response;
}
var delay = GetRetryDelay(response, attempt);
response.Dispose();
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
await Task.Delay(delay < TimeSpan.Zero ? TimeSpan.Zero : delay, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (attempt >= maxAttempts)
catch (Exception ex) when (!cancellationToken.IsCancellationRequested && ex is HttpRequestException or OperationCanceledException)
{
Record(request, ex.GetType().Name, attempt);
if (attempt >= maxAttempts)
{
throw;
}
@@ -23,11 +23,10 @@ public sealed class TmdbClient
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("TMDb API key is required.");
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
var http = _httpClientFactory.CreateClient();
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}?api_key={Uri.EscapeDataString(apiKey)}&language={Uri.EscapeDataString(language)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken, rateLimiter).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
@@ -58,11 +57,10 @@ public sealed class TmdbClient
if (item.Kind is "tvepisode")
return new TmdbImages([], [], []);
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
var http = _httpClientFactory.CreateClient();
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}/images?api_key={Uri.EscapeDataString(apiKey)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken, rateLimiter).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
@@ -0,0 +1,220 @@
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Microsoft.Extensions.Logging;
using static Jellyfin.Plugin.Multilang.MultilangConstants;
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public sealed class ItemFetcher(
TranslationStore store, TmdbClient tmdb, FanartClient fanart,
AssetStorageService storage, ILogger<ItemFetcher> logger)
{
private readonly FetchRateLimiter _limiter = new(TimeSpan.FromMilliseconds(250));
private static string Scope(RefreshItemInfo item)
=> $"{item.Kind}:{item.TmdbId}:{item.SeasonNumber}:{item.EpisodeNumber}";
public static bool NeedsFetch(FetchState? state, string scope, bool dataMissing, bool full, long cutoff)
=> full || state is null || state.Scope != scope ||
(dataMissing && (state.Complete || state.CheckedAt <= cutoff)) ||
(!state.Complete && state.CheckedAt <= cutoff);
public bool NeedsLocalWork(string itemId, PluginConfiguration cfg)
=> cfg.AssetStorageMode == "local" && store.GetStoredAssets(itemId).Any(asset => asset.SourceUrl.Length > 0 &&
(!store.TryNormalizeLocalAssetPath(asset.Path, out var path) || !File.Exists(path)));
public bool NeedsRefresh(RefreshItemInfo item, PluginConfiguration cfg)
{
var states = store.GetFetchStates(item.ItemId);
var languages = Languages(cfg);
var texts = store.GetTranslations([item.ItemId], languages.Append(OriginalAction)).GetValueOrDefault(item.ItemId) ?? [];
var cutoff = MissingCutoff(cfg);
var original = store.GetFacts(item.ItemId)?.OriginalLanguage ?? "";
foreach (var lang in languages)
if (NeedsMetadata(item, lang, lang, texts, states, false, cutoff))
return true;
if (original.Length > 0 && !languages.Any(lang => SameLanguage(lang, original)) &&
NeedsMetadata(item, OriginalAction, original, texts, states, false, cutoff))
return true;
if (original.Length > 0 && languages.Any(lang => SameLanguage(lang, original)) && !texts.ContainsKey(OriginalAction))
return true;
var assets = store.GetStoredAssets(item.ItemId);
if (cfg.AssetStorageMode == "local" && assets.Any(asset => asset.SourceUrl.Length > 0 &&
(!store.TryNormalizeLocalAssetPath(asset.Path, out var path) || !File.Exists(path))))
return true;
var providers = Providers(item, cfg);
var scope = ArtworkScope(item, languages, original, providers);
return providers.Any(provider => NeedsFetch(states.GetValueOrDefault("artwork:" + provider), scope,
ArtworkMissing(assets, languages.Append(OriginalAction).Where(lang => lang != OriginalAction || original.Length > 0), providers, provider), false, cutoff));
}
public async Task<bool> RefreshAsync(RefreshItemInfo item, PluginConfiguration cfg, RefreshJobType job, CancellationToken ct)
{
var full = job == RefreshJobType.Full;
var localOnly = job == RefreshJobType.Aggregate;
var states = store.GetFetchStates(item.ItemId);
var languages = Languages(cfg);
var texts = store.GetTranslations([item.ItemId], languages.Append(OriginalAction)).GetValueOrDefault(item.ItemId) ?? [];
var cutoff = MissingCutoff(cfg);
var fetched = 0;
async Task FetchMetadata(string storedLanguage, string requestedLanguage)
{
if (localOnly || !NeedsMetadata(item, storedLanguage, requestedLanguage, texts, states, full, cutoff))
return;
var metadata = await tmdb.FetchMetadataAsync(item, requestedLanguage, cfg.TmdbApiKey, _limiter, ct).ConfigureAwait(false);
var complete = metadata is not null && metadata.Title.Length > 0 && metadata.Overview.Length > 0 &&
(item.Kind != "movie" || metadata.Tagline.Length > 0);
store.SaveMetadata(item, storedLanguage, metadata,
new(Scope(item) + ":" + requestedLanguage, TranslationStore.NowUnixUtc(), complete), full,
item.Kind is "movie" or "tv" || storedLanguage == languages[0]);
if (metadata is not null)
Interlocked.Increment(ref fetched);
if (cfg.EnableLogging)
logger.LogInformation("TMDb metadata {Result} item={ItemId} lang={Language} storedAs={StoredLanguage}",
metadata is null ? "404" : "fetched", item.ItemId, requestedLanguage, storedLanguage);
}
await Parallel.ForEachAsync(languages, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
async (lang, _) => await FetchMetadata(lang, lang).ConfigureAwait(false)).ConfigureAwait(false);
var original = store.GetFacts(item.ItemId)?.OriginalLanguage ?? "";
if (original.Length > 0)
{
var configured = languages.FirstOrDefault(lang => SameLanguage(lang, original));
if (configured is not null)
store.CopyOriginalTranslations(item.ItemId, configured);
else
await FetchMetadata(OriginalAction, original).ConfigureAwait(false);
}
var artworkFetched = await RefreshArtwork(item, cfg, languages, original, states, full, localOnly, cutoff, ct).ConfigureAwait(false);
if (!localOnly)
store.MarkFactsChecked(item, TranslationStore.NowUnixUtc(), full ? TranslationStore.NowUnixUtc() : 0);
return fetched > 0 || artworkFetched;
}
private static bool NeedsMetadata(RefreshItemInfo item, string storedLanguage, string requestedLanguage,
Dictionary<string, Dictionary<string, string>> texts, Dictionary<string, FetchState> states, bool full, long cutoff)
{
var fields = texts.GetValueOrDefault(storedLanguage);
var missing = fields is null || TextFields.Any(field => !fields.ContainsKey(field) ||
((field != TaglineField || item.Kind == "movie") && string.IsNullOrWhiteSpace(fields[field])));
return NeedsFetch(states.GetValueOrDefault("metadata:" + storedLanguage),
Scope(item) + ":" + requestedLanguage, missing, full, cutoff);
}
private async Task<bool> RefreshArtwork(RefreshItemInfo item, PluginConfiguration cfg, string[] languages,
string original, Dictionary<string, FetchState> states, bool full, bool localOnly, long cutoff, CancellationToken ct)
{
if (localOnly)
{
if (cfg.AssetStorageMode != "local")
return false;
var assets = store.GetStoredAssets(item.ItemId);
var repaired = new List<StoredAsset>();
foreach (var asset in assets)
{
var path = asset.SourceUrl.Length == 0 ? asset.Path
: await storage.StoreAsync(item.ItemId, asset.SourceUrl, true, ct).ConfigureAwait(false);
repaired.Add(asset with { Path = path! });
}
store.SaveArtwork(item.ItemId, repaired, new Dictionary<string, FetchState>());
return true;
}
var providers = Providers(item, cfg);
var priority = providers.Select((provider, index) => (provider, index)).ToDictionary(p => p.provider, p => p.index);
var wanted = languages.ToDictionary(lang => lang, BaseLanguage, StringComparer.OrdinalIgnoreCase);
if (original.Length > 0)
wanted[OriginalAction] = BaseLanguage(original);
var previous = store.GetStoredAssets(item.ItemId);
var selected = previous.Where(asset => wanted.ContainsKey(asset.Language) && priority.ContainsKey(asset.Provider))
.ToDictionary(asset => (asset.Language, asset.Kind));
var updates = new Dictionary<string, FetchState>();
var scope = ArtworkScope(item, languages, original, providers);
foreach (var provider in providers)
{
var kinds = ArtworkKinds(provider);
var keys = wanted.Keys.SelectMany(lang => kinds.Select(kind => (Language: lang, Kind: kind))).ToArray();
var missing = keys.Any(key => !selected.TryGetValue(key, out var asset) || priority[asset.Provider] > priority[provider]);
if (localOnly || !NeedsFetch(states.GetValueOrDefault("artwork:" + provider), scope, missing, full, cutoff))
continue;
Dictionary<string, Dictionary<string, string>> images;
if (provider == "tmdb")
{
var response = await tmdb.FetchImagesAsync(item, cfg.TmdbApiKey, _limiter, ct).ConfigureAwait(false);
images = new() { [PosterKind] = response?.Posters ?? [], [LogoKind] = response?.Logos ?? [], [BackdropKind] = response?.Backdrops ?? [] };
}
else
{
var response = await fanart.FetchMovieImagesAsync(item.TmdbId, cfg.FanartApiKey, ct).ConfigureAwait(false);
images = new() { [PosterKind] = response?.Posters ?? [], [LogoKind] = response?.Logos ?? [], [BannerKind] = response?.Banners ?? [], [ThumbKind] = response?.Thumbs ?? [] };
}
var complete = true;
foreach (var key in keys)
{
selected.TryGetValue(key, out var existing);
var url = images[key.Kind].GetValueOrDefault(wanted[key.Language]);
if (string.IsNullOrWhiteSpace(url))
{
complete = false;
if (existing?.Provider == provider)
selected.Remove(key);
}
else if (existing is null || priority[provider] <= priority[existing.Provider])
{
// Keep an existing local path until the scheduled storage-mode cleanup.
var path = existing?.SourceUrl == url ? existing.Path : url;
selected[key] = new(key.Language, key.Kind, path, url, provider);
}
}
updates["artwork:" + provider] = new(scope, TranslationStore.NowUnixUtc(), complete);
if (cfg.EnableLogging && cfg.VerboseLogging)
logger.LogInformation("Artwork fetched item={ItemId} provider={Provider} languages={Languages}",
item.ItemId, provider, string.Join(",", wanted.Keys));
}
var changed = updates.Count > 0;
if (cfg.AssetStorageMode == "local")
{
foreach (var key in selected.Keys.ToArray())
{
var asset = selected[key];
var path = await storage.StoreAsync(item.ItemId, asset.SourceUrl, true, ct).ConfigureAwait(false);
selected[key] = asset with { Path = path! };
changed |= path != asset.Path;
}
}
var result = selected.Values.ToArray();
if (changed || !previous.OrderBy(a => a.Language).ThenBy(a => a.Kind).SequenceEqual(result.OrderBy(a => a.Language).ThenBy(a => a.Kind)))
store.SaveArtwork(item.ItemId, result, updates);
return changed;
}
private static string[] ArtworkKinds(string provider)
=> provider == "tmdb" ? [PosterKind, LogoKind, BackdropKind] : [PosterKind, LogoKind, BannerKind, ThumbKind];
private static bool ArtworkMissing(IEnumerable<StoredAsset> assets, IEnumerable<string> languages, string[] providers, string provider)
{
var available = assets.Where(asset => Array.IndexOf(providers, asset.Provider) is var rank && rank >= 0 && rank <= Array.IndexOf(providers, provider))
.Select(asset => (asset.Language, asset.Kind)).ToHashSet();
return languages.Any(lang => ArtworkKinds(provider).Any(kind => !available.Contains((lang, kind))));
}
private static string[] Providers(RefreshItemInfo item, PluginConfiguration cfg)
=> item.Kind == "tvepisode" ? [] : cfg.Providers.Where(p => p.ArtworkOrder >= 0)
.OrderBy(p => p.ArtworkOrder).Select(p => p.Id.Trim().ToLowerInvariant())
.Where(p => p == "tmdb" || (p == "fanart" && item.Kind == "movie" && !string.IsNullOrWhiteSpace(cfg.FanartApiKey))).ToArray();
private static string ArtworkScope(RefreshItemInfo item, string[] languages, string original, string[] providers)
=> Scope(item) + "|" + string.Join(",", languages.Order(StringComparer.OrdinalIgnoreCase)) + "|" + original + "|" + string.Join(",", providers);
private static long MissingCutoff(PluginConfiguration cfg)
=> TranslationStore.NowUnixUtc() - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForMissingData)).TotalSeconds;
private static string[] Languages(PluginConfiguration cfg)
=> cfg.Languages.Select(lang => lang.Trim()).Where(lang => lang.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
private static string BaseLanguage(string lang) => lang.Split('-')[0].ToLowerInvariant();
private static bool SameLanguage(string left, string right) => BaseLanguage(left) == BaseLanguage(right);
}
@@ -3,7 +3,7 @@ using System.Diagnostics;
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public sealed class RefreshCoordinator
public sealed class RefreshCoordinator : IDisposable
{
private sealed class QueueEntry
{
@@ -20,21 +20,22 @@ public sealed class RefreshCoordinator
public required TaskCompletionSource<bool> Completion { get; init; }
public long Sequence { get; init; }
public bool Active { get; set; }
}
private readonly object _lock = new();
private readonly Dictionary<string, QueueEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
private readonly Queue<RefreshRecentItem> _recent = new();
private readonly SemaphoreSlim _signal = new(0, int.MaxValue);
private readonly ItemsProxyCache _itemsProxyCache;
private long _sequence;
private QueueEntry? _active;
private readonly CancellationTokenSource _stop = new();
public SemaphoreSlim ScanMutex { get; } = new(1, 1);
public RefreshCoordinator(ItemsProxyCache itemsProxyCache)
public RefreshCoordinator(int concurrency = MultilangConstants.RefreshConcurrency)
{
_itemsProxyCache = itemsProxyCache;
ArgumentOutOfRangeException.ThrowIfLessThan(concurrency, 1);
for (var i = 0; i < concurrency; i++)
_ = Task.Run(WorkerLoop);
}
@@ -53,6 +54,7 @@ public sealed class RefreshCoordinator
lock (_lock)
{
ObjectDisposedException.ThrowIf(_stop.IsCancellationRequested, this);
if (_entries.TryGetValue(itemId, out var existing))
{
if (jobType > existing.JobType)
@@ -64,7 +66,7 @@ public sealed class RefreshCoordinator
if (sourceTier > existing.SourceTier)
existing.SourceTier = sourceTier;
return existing.Completion.Task;
return cancellationToken.CanBeCanceled ? existing.Completion.Task.WaitAsync(cancellationToken) : existing.Completion.Task;
}
var entry = new QueueEntry
@@ -80,7 +82,7 @@ public sealed class RefreshCoordinator
_entries.Add(itemId, entry);
_signal.Release();
return entry.Completion.Task;
return cancellationToken.CanBeCanceled ? entry.Completion.Task.WaitAsync(cancellationToken) : entry.Completion.Task;
}
}
@@ -88,7 +90,8 @@ public sealed class RefreshCoordinator
{
while (true)
{
await _signal.WaitAsync().ConfigureAwait(false);
try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
QueueEntry? entry;
lock (_lock)
@@ -102,12 +105,24 @@ public sealed class RefreshCoordinator
var error = string.Empty;
try
{
while (true)
{
RefreshJobType executing;
Func<RefreshJobType, CancellationToken, Task<bool>> work;
lock (_lock)
_active = entry;
ok = await entry.Work(entry.JobType, CancellationToken.None).ConfigureAwait(false);
if (ok)
_itemsProxyCache.InvalidateItems([entry.ItemId]);
{
executing = entry.JobType;
work = entry.Work;
}
ok |= await work(executing, _stop.Token).ConfigureAwait(false);
lock (_lock)
{
if (entry.JobType > executing)
continue;
_entries.Remove(entry.ItemId);
break;
}
}
entry.Completion.TrySetResult(ok);
}
catch (Exception ex)
@@ -120,8 +135,7 @@ public sealed class RefreshCoordinator
sw.Stop();
lock (_lock)
{
if (ReferenceEquals(_active, entry))
_active = null;
if (_entries.TryGetValue(entry.ItemId, out var current) && ReferenceEquals(current, entry))
_entries.Remove(entry.ItemId);
_recent.Enqueue(new RefreshRecentItem(
entry.ItemId,
@@ -144,11 +158,14 @@ public sealed class RefreshCoordinator
if (_entries.Count == 0)
return null;
return _entries.Values
var entry = _entries.Values
.Where(e => !e.Active)
.OrderByDescending(e => e.SourceTier)
.ThenBy(e => e.WorkClass)
.ThenBy(e => e.Sequence)
.First();
.FirstOrDefault();
if (entry is not null) entry.Active = true;
return entry;
}
public RefreshQueueDiagnostics GetDiagnostics()
@@ -156,7 +173,7 @@ public sealed class RefreshCoordinator
lock (_lock)
{
var queued = _entries.Values
.Where(e => !ReferenceEquals(e, _active))
.Where(e => !e.Active)
.OrderByDescending(e => e.SourceTier)
.ThenBy(e => e.WorkClass)
.ThenBy(e => e.Sequence)
@@ -166,7 +183,7 @@ public sealed class RefreshCoordinator
return new RefreshQueueDiagnostics(
queued.Length,
queued,
_active is null ? null : ToDiagnostics(_active),
_entries.Values.Where(e => e.Active).Select(ToDiagnostics).ToArray(),
_recent.Reverse().ToArray());
}
}
@@ -178,4 +195,15 @@ public sealed class RefreshCoordinator
entry.WorkClass.ToString(),
entry.JobType.ToString(),
entry.Sequence);
public void Dispose()
{
_stop.Cancel();
lock (_lock)
{
foreach (var entry in _entries.Values)
entry.Completion.TrySetCanceled();
_entries.Clear();
}
}
}
@@ -53,7 +53,7 @@ public sealed record RefreshRecentItem(
public sealed record RefreshQueueDiagnostics(
int QueuedCount,
RefreshQueueEntry[] Queued,
RefreshQueueEntry? Active,
RefreshQueueEntry[] ActiveItems,
RefreshRecentItem[] Recent);
public sealed record RefreshScanDiagnostics(
@@ -2,7 +2,6 @@ using System.Diagnostics;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
using Jellyfin.Plugin.Multilang.Services.Providers;
using static Jellyfin.Plugin.Multilang.MultilangConstants;
using MediaBrowser.Controller.Entities;
@@ -16,9 +15,7 @@ public sealed class RefreshService
{
private readonly ILibraryManager _libraryManager;
private readonly TranslationStore _store;
private readonly TmdbClient _tmdbClient;
private readonly FanartClient _fanartClient;
private readonly AssetStorageService _assetStorage;
private readonly ItemFetcher _fetcher;
private readonly RefreshCoordinator _coordinator;
private readonly ILogger<RefreshService> _logger;
private readonly object _diagnosticsLock = new();
@@ -27,17 +24,13 @@ public sealed class RefreshService
public RefreshService(
ILibraryManager libraryManager,
TranslationStore store,
TmdbClient tmdbClient,
FanartClient fanartClient,
AssetStorageService assetStorage,
ItemFetcher fetcher,
RefreshCoordinator coordinator,
ILogger<RefreshService> logger)
{
_libraryManager = libraryManager;
_store = store;
_tmdbClient = tmdbClient;
_fanartClient = fanartClient;
_assetStorage = assetStorage;
_fetcher = fetcher;
_coordinator = coordinator;
_logger = logger;
}
@@ -151,7 +144,6 @@ public sealed class RefreshService
UpdateRunningScan();
var items = GetAllItemsWithTmdbId();
var now = TranslationStore.NowUnixUtc();
var missingCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForMissingData)).TotalSeconds;
var fullCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForExistingData)).TotalSeconds;
var ordered = items.OrderBy(i => i.WorkClass).ToList();
itemCount = ordered.Count;
@@ -162,7 +154,14 @@ public sealed class RefreshService
// Run local housekeeping only from full-library scans. ItemsProxy browsing may enqueue
// missing items, but it must not delete local assets just because the admin briefly
// changed artwork storage mode.
var localCleanup = _store.CleanupForConfiguration(liveIds, NormalizeLanguages(cfg.Languages), IsLocalAssetStorage(cfg));
LocalCleanupResult localCleanup;
using (await _store.EnterMaintenanceAsync(cancellationToken).ConfigureAwait(false))
{
cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin unavailable.");
if (Plugin.Instance.Uninstalling)
throw new OperationCanceledException("Plugin is being uninstalled.");
localCleanup = _store.CleanupForConfiguration(liveIds, NormalizeLanguages(cfg.Languages), IsLocalAssetStorage(cfg));
}
if (localCleanup.TranslationsDeleted > 0 || localCleanup.AssetsDeleted > 0 || localCleanup.GenresDeleted > 0 || localCleanup.AssetFilesDeleted > 0)
{
_logger.LogInformation(
@@ -176,41 +175,48 @@ public sealed class RefreshService
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
var index = 0;
UpdateRunningScan();
foreach (var item in ordered)
var completed = 0;
var scanLock = new object();
foreach (var group in ordered.GroupBy(item => item.WorkClass))
await Parallel.ForEachAsync(group,
new ParallelOptions { MaxDegreeOfParallelism = RefreshConcurrency, CancellationToken = cancellationToken },
async (item, ct) =>
{
cancellationToken.ThrowIfCancellationRequested();
index++;
SetCurrent(item, index, "checking");
ct.ThrowIfCancellationRequested();
var scanIndex = Interlocked.Increment(ref index);
var status = _store.GetFactsStatus(item.ItemId);
var isNew = !status.Exists;
var missingConfiguredData = jobType == RefreshJobType.Missing && status.Exists && ItemNeedsMissingRefresh(item, cfg, langs: null);
var due = jobType == RefreshJobType.Full
? !status.Exists || status.FullCheckedAt < fullCutoff
: !status.Exists || status.MissingCheckedAt < missingCutoff || missingConfiguredData;
var fullDue = jobType == RefreshJobType.Full && (!status.Exists || status.FullCheckedAt < fullCutoff);
var due = fullDue || (jobType == RefreshJobType.Missing
? !status.Exists || _fetcher.NeedsRefresh(item, cfg)
: _fetcher.NeedsLocalWork(item.ItemId, cfg));
if (!due)
{
skippedNotDueCount++;
Interlocked.Increment(ref skippedNotDueCount);
if (cfg.VerboseLogging && cfg.EnableLogging)
{
_logger.LogInformation(
"Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due",
index,
scanIndex,
ordered.Count,
item.ItemId,
item.DisplayName,
item.Kind);
}
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan("skipped-not-due");
continue;
lock (scanLock)
{
SetCurrent(item, completed + 1, "skipped-not-due");
progress.Report(++completed * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan();
}
return;
}
var scanIndex = index;
var actualJobType = jobType == RefreshJobType.Full || !status.Exists ? RefreshJobType.Full : RefreshJobType.Missing;
dueCount++;
SetCurrent(item, scanIndex, "fetching");
var actualJobType = fullDue || !status.Exists ? RefreshJobType.Full
: jobType == RefreshJobType.Full ? RefreshJobType.Aggregate : RefreshJobType.Missing;
Interlocked.Increment(ref dueCount);
Log(
cfg.EnableLogging,
LogLevel.Information,
@@ -229,19 +235,30 @@ public sealed class RefreshService
RefreshSourceTier.Background,
item.WorkClass,
actualJobType,
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, ordered.Count, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
async (actualJobType, ct) => await RefreshItemCoreAsync(item, actualJobType, ct).ConfigureAwait(false),
ct).ConfigureAwait(false);
lock (scanLock)
{
if (ok)
refreshedCount++;
else
skippedNoDataCount++;
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan(ok ? "refreshed" : "skipped-no-data");
progress.Report(++completed * 100.0 / Math.Max(1, ordered.Count));
SetCurrent(item, completed, ok ? "refreshed" : "skipped-no-data");
UpdateRunningScan();
}
}).ConfigureAwait(false);
using (await _store.EnterMaintenanceAsync(cancellationToken).ConfigureAwait(false))
{
if (Plugin.Instance!.Uninstalling)
throw new OperationCanceledException("Plugin is being uninstalled.");
_store.CleanupUnreferencedAssetFiles();
_store.SetLastScanStarted(now);
}
sw.Stop();
_store.SetLastScanStarted(now);
CompleteScan(true, string.Empty);
_logger.LogInformation(
"Multilang refresh complete mode={Mode} source={Source} elapsed={ElapsedMs}ms items={Items} due={Due} refreshed={Refreshed} skippedNotDue={SkippedNotDue} skippedNoData={SkippedNoData} new={New} deleted={Deleted}",
@@ -268,7 +285,8 @@ public sealed class RefreshService
}
}
public async Task RefreshItemAsync(Guid itemId, bool includeChildren, CancellationToken cancellationToken)
public async Task RefreshItemAsync(Guid itemId, bool includeChildren, CancellationToken cancellationToken,
Jellyfin.Database.Implementations.Entities.User user)
{
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
ValidateRefreshConfiguration(cfg);
@@ -279,14 +297,17 @@ public sealed class RefreshService
return;
}
var items = BuildItemInfos(GetItemAndChildren(root, includeChildren)).ToList();
var items = BuildItemInfos(GetItemAndChildren(root, includeChildren)
.Where(item => item.IsVisibleStandalone(user))).ToList();
var total = items.Count;
var index = 0;
_logger.LogInformation("Manual Multilang refresh started item={ItemId} name={Name} items={Items} includeChildren={IncludeChildren}", itemId, root.Name ?? string.Empty, total, includeChildren);
foreach (var item in items)
foreach (var group in items.OrderBy(item => item.WorkClass).GroupBy(item => item.WorkClass))
await Parallel.ForEachAsync(group,
new ParallelOptions { MaxDegreeOfParallelism = RefreshConcurrency, CancellationToken = cancellationToken },
async (item, ct) =>
{
index++;
var scanIndex = index;
var scanIndex = Interlocked.Increment(ref index);
Log(
cfg.EnableLogging,
LogLevel.Information,
@@ -303,8 +324,8 @@ public sealed class RefreshService
RefreshSourceTier.Manual,
item.WorkClass,
RefreshJobType.Full,
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, total, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
async (actualJobType, ct) => await RefreshItemCoreAsync(item, actualJobType, ct).ConfigureAwait(false),
ct).ConfigureAwait(false);
Log(
cfg.EnableLogging,
ok ? LogLevel.Information : LogLevel.Warning,
@@ -315,7 +336,7 @@ public sealed class RefreshService
item.ItemId,
item.DisplayName,
item.Kind);
}
}).ConfigureAwait(false);
}
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
@@ -365,7 +386,7 @@ public sealed class RefreshService
sourceTier,
info.WorkClass,
RefreshJobType.Full,
async (actualJobType, ct) => await RefreshItemCoreAsync(info, cfg, actualJobType, 0, 0, ct).ConfigureAwait(false),
async (actualJobType, ct) => await RefreshItemCoreAsync(info, actualJobType, ct).ConfigureAwait(false),
CancellationToken.None)
.ContinueWith(
task =>
@@ -388,245 +409,16 @@ public sealed class RefreshService
private async Task<bool> RefreshItemCoreAsync(
RefreshItemInfo item,
PluginConfiguration cfg,
RefreshJobType jobType,
int scanIndex,
int totalItems,
CancellationToken cancellationToken)
{
var langs = NormalizeLanguages(cfg.Languages);
if (langs.Length == 0)
throw new InvalidOperationException("No Multilang languages are configured.");
if (cfg.VerboseLogging && cfg.EnableLogging)
_logger.LogInformation("[{Index}/{Total}] Fetching item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.Kind, item.TmdbId);
var now = TranslationStore.NowUnixUtc();
var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(250));
var wroteAny = false;
TmdbMetadata? firstMeta = null;
var fetchedMetadata = new Dictionary<string, TmdbMetadata>(StringComparer.OrdinalIgnoreCase);
foreach (var lang in langs)
{
var meta = await _tmdbClient.FetchMetadataAsync(item, lang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (meta is null)
{
Log(cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb 404, skipping item={ItemId} name=\"{Name}\" tmdb={TmdbId} kind={Kind}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.TmdbId, item.Kind);
_store.MarkFactsChecked(item, now, jobType == RefreshJobType.Full ? now : 0);
return false;
}
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb metadata ok item={ItemId} lang={Lang}", scanIndex, totalItems, item.ItemId, lang);
_store.UpsertTranslation(item.ItemId, lang, TitleField, meta.Title);
_store.UpsertTranslation(item.ItemId, lang, OverviewField, meta.Overview);
_store.UpsertTranslation(item.ItemId, lang, TaglineField, meta.Tagline);
StoreGenres(item, lang, meta);
fetchedMetadata[lang] = meta;
firstMeta ??= meta;
if (!wroteAny)
{
_store.UpsertFacts(item, meta, now, jobType == RefreshJobType.Full ? now : 0);
wroteAny = true;
}
}
var artworkLanguages = langs;
if (firstMeta is not null && !string.IsNullOrWhiteSpace(firstMeta.OriginalLanguage))
{
var originalLang = firstMeta.OriginalLanguage.Trim();
var existingLang = langs.FirstOrDefault(lang => SameLanguageBase(lang, originalLang));
Log(
cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Original language item={ItemId} lang={OriginalLang} metadataSource={Source}",
scanIndex,
totalItems,
item.ItemId,
originalLang,
existingLang is not null ? "configured-language:" + existingLang : "extra-fetch");
var originalMeta = existingLang is not null
? fetchedMetadata[existingLang]
: await _tmdbClient.FetchMetadataAsync(item, originalLang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (originalMeta is not null)
{
_store.UpsertTranslation(item.ItemId, OriginalAction, TitleField, originalMeta.Title);
_store.UpsertTranslation(item.ItemId, OriginalAction, OverviewField, originalMeta.Overview);
_store.UpsertTranslation(item.ItemId, OriginalAction, TaglineField, originalMeta.Tagline);
StoreGenres(item, OriginalAction, originalMeta);
Log(
cfg.VerboseLogging && cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Original metadata stored item={ItemId} lang={OriginalLang} titlePresent={TitlePresent} overviewPresent={OverviewPresent} taglinePresent={TaglinePresent}",
scanIndex,
totalItems,
item.ItemId,
originalLang,
!string.IsNullOrWhiteSpace(originalMeta.Title),
!string.IsNullOrWhiteSpace(originalMeta.Overview),
!string.IsNullOrWhiteSpace(originalMeta.Tagline));
}
else
{
Log(
cfg.EnableLogging,
LogLevel.Warning,
"[{Index}/{Total}] Original metadata unavailable item={ItemId} lang={OriginalLang}",
scanIndex,
totalItems,
item.ItemId,
originalLang);
}
artworkLanguages = AppendOriginalLanguage(langs, originalLang);
Log(
cfg.VerboseLogging && cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Artwork language set item={ItemId} langs={Languages}",
scanIndex,
totalItems,
item.ItemId,
string.Join(",", artworkLanguages));
}
await StoreArtworkAsync(item, cfg, artworkLanguages, limiter, now, cancellationToken).ConfigureAwait(false);
Log(cfg.EnableLogging, LogLevel.Information, "Multilang refresh item complete item={ItemId} kind={Kind} tmdb={TmdbId} mode={Mode}", item.ItemId, item.Kind, item.TmdbId, jobType);
return true;
}
private void StoreGenres(RefreshItemInfo item, string lang, TmdbMetadata metadata)
{
var media = GenreMediaFor(item.Kind);
if (media is null)
return;
foreach (var genre in metadata.Genres)
_store.UpsertGenre(genre.Id, media, lang, genre.Name);
}
private static string? GenreMediaFor(string kind)
=> kind.Equals("movie", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("collection", StringComparison.OrdinalIgnoreCase)
? "movie"
: kind.Equals("tv", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("tvseason", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("tvepisode", StringComparison.OrdinalIgnoreCase)
? "tv"
: null;
private async Task StoreArtworkAsync(
RefreshItemInfo item,
PluginConfiguration cfg,
string[] languages,
FetchRateLimiter limiter,
long updatedAt,
CancellationToken cancellationToken)
{
if (item.Kind is "tvepisode")
return;
var providers = cfg.Providers
.Where(p => p.ArtworkOrder >= 0)
.OrderBy(p => p.ArtworkOrder)
.Select(p => p.Id.Trim().ToLowerInvariant())
.ToArray();
if (providers.Length == 0)
return;
TmdbImages? tmdbImages = null;
FanartImages? fanartImages = null;
var written = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var provider in providers)
{
cancellationToken.ThrowIfCancellationRequested();
if (provider.Equals("tmdb", StringComparison.OrdinalIgnoreCase))
{
tmdbImages ??= await _tmdbClient.FetchImagesAsync(item, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (tmdbImages is null)
continue;
await StoreImageMapAsync(item.ItemId, languages, PosterKind, tmdbImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, LogoKind, tmdbImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, BackdropKind, tmdbImages.Backdrops, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
continue;
}
if (provider.Equals("fanart", StringComparison.OrdinalIgnoreCase) && item.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase))
{
fanartImages ??= await _fanartClient.FetchMovieImagesAsync(item.TmdbId, cfg.FanartApiKey, cancellationToken).ConfigureAwait(false);
if (fanartImages is null)
continue;
await StoreImageMapAsync(item.ItemId, languages, PosterKind, fanartImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, LogoKind, fanartImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, BannerKind, fanartImages.Banners, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, ThumbKind, fanartImages.Thumbs, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
}
}
}
private async Task StoreImageMapAsync(
string itemId,
IEnumerable<string> languages,
string kind,
IReadOnlyDictionary<string, string> images,
PluginConfiguration cfg,
long updatedAt,
HashSet<string> written,
CancellationToken cancellationToken)
{
foreach (var lang in languages)
{
var storeLang = lang.StartsWith(OriginalAction + ":", StringComparison.OrdinalIgnoreCase)
? OriginalAction
: lang;
var key = kind + ":" + storeLang;
if (written.Contains(key))
continue;
var iso = ToIso639(lang);
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
continue;
var storedPath = await _assetStorage.StoreAsync(itemId, storeLang, kind, url, IsLocalAssetStorage(cfg), cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(storedPath))
continue;
_store.UpsertAsset(itemId, storeLang, kind, storedPath, updatedAt);
written.Add(key);
}
}
private bool ItemNeedsMissingRefresh(RefreshItemInfo item, PluginConfiguration cfg, string[]? langs)
{
langs ??= NormalizeLanguages(cfg.Languages);
if (_store.HasMissingConfiguredTranslations(item.ItemId, langs))
return true;
var assetPresence = _store.GetAssetPresence(item.ItemId, langs.Append(OriginalAction));
return IsLocalAssetStorage(cfg) && assetPresence.AnyRemote;
}
private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
=> languages
.Concat([OriginalAction + ":" + originalLanguage])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static bool SameLanguageBase(string left, string right)
=> ToIso639(left).Equals(ToIso639(right), StringComparison.OrdinalIgnoreCase);
private static string ToIso639(string language)
{
if (language.StartsWith(OriginalAction + ":", StringComparison.OrdinalIgnoreCase))
language = language[(OriginalAction.Length + 1)..];
var index = language.IndexOf('-', StringComparison.Ordinal);
return (index > 0 ? language[..index] : language).Trim().ToLowerInvariant();
using var lease = await _store.EnterRefreshAsync(cancellationToken).ConfigureAwait(false);
var plugin = Plugin.Instance ?? throw new InvalidOperationException("Plugin unavailable.");
if (plugin.Uninstalling)
throw new OperationCanceledException("Plugin is being uninstalled.");
var cfg = plugin.Configuration;
ValidateRefreshConfiguration(cfg);
return await _fetcher.RefreshAsync(item, cfg, jobType, cancellationToken).ConfigureAwait(false);
}
private List<RefreshItemInfo> GetAllItemsWithTmdbId()
+34 -25
View File
@@ -3,7 +3,6 @@
const MENU_ID = "mlUserRulesMenuItem";
const USER_RULES_ROUTE = "multilang/user-rules";
let lastMenuItemId = "";
let userRulesRenderRun = 0;
function getClientToken() {
try {
@@ -44,6 +43,11 @@
return parts.path === USER_RULES_ROUTE && parts.params.get("multilang") === "1";
}
function isPreferencesMenuRoute() {
const path = hashParts().path;
return path === "mypreferences" || path === "mypreferencesmenu";
}
function pathSegments(pathname) {
return String(pathname || "")
.toLowerCase()
@@ -120,7 +124,7 @@
function ensureUserRulesLink() {
let menuItem = document.getElementById(MENU_ID);
if (!(location.hash || "").toLowerCase().includes("mypreferences")) {
if (!isPreferencesMenuRoute()) {
menuItem?.remove();
return;
}
@@ -132,7 +136,8 @@
return;
}
const links = [...document.querySelectorAll('a[href^="#/mypreferences"]')];
const preferencesPage = visiblePage();
const links = preferencesPage ? [...preferencesPage.querySelectorAll('a[href^="#/mypreferences"]')] : [];
const insertAfter = links[links.length - 1];
if (!insertAfter) return;
@@ -214,6 +219,7 @@
function closeUserRulesShellIfInactive() {
if (isUserRulesRoute()) return;
document.querySelector("#ml-user-shell #ml-user")?._mlDispose?.();
document.getElementById("ml-user-shell")?.remove();
}
@@ -236,32 +242,25 @@
const next = document.createElement("script");
[...script.attributes].forEach((attr) => next.setAttribute(attr.name, attr.value));
if (script.src) {
next.onload = () => resolve();
next.onerror = () => reject(new Error(`Failed to load ${script.src}`));
next.onload = () => { next.remove(); resolve(); };
next.onerror = () => { next.remove(); reject(new Error(`Failed to load ${script.src}`)); };
next.src = script.src;
} else {
next.text = script.textContent || "";
}
document.body.appendChild(next);
if (!script.src) resolve();
if (!script.src) { next.remove(); resolve(); }
});
}
async function renderUserRulesPage(run) {
if (!isUserRulesRoute() || run !== userRulesRenderRun) return;
const host = userRulesHost();
if (!host) return;
if (host.dataset.mlUserRulesRendered === "1" && document.getElementById("ml-user")) return;
host.dataset.mlUserRulesRendered = "1";
async function renderUserRulesPage(host) {
host.innerHTML = "<div class=\"fieldDescription\">Loading Multilang settings...</div>";
const response = await fetch(`${BASE_PATH}/web/configurationpage?name=MultilangUser`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const fragment = await response.text();
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
if (!host.isConnected || !isUserRulesRoute()) return;
const parsed = new DOMParser().parseFromString(fragment, "text/html");
const sourcePage = parsed.getElementById("ml-user");
@@ -275,26 +274,26 @@
page.id = "ml-user";
page.className = "ml-root";
while (sourceContent.firstChild) page.appendChild(sourceContent.firstChild);
host.replaceChildren(page);
host.textContent = "";
host.appendChild(page);
for (const script of scripts) {
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
if (!host.isConnected || !isUserRulesRoute()) return;
await executeScript(script);
}
}
function scheduleUserRulesPageRender() {
userRulesRenderRun++;
if (!isUserRulesRoute()) {
closeUserRulesShellIfInactive();
return;
}
const run = userRulesRenderRun;
[0, 100, 350].forEach((delay) => {
setTimeout(() => {
renderUserRulesPage(run).catch((err) => console.error("[Multilang] failed to render user rules page", err));
}, delay);
const host = userRulesHost();
if (!host || host._mlLoad) return;
host._mlLoad = renderUserRulesPage(host).catch((err) => {
console.error("[Multilang] failed to render user rules page", err);
if (host.isConnected) host.textContent = `Unable to load Multilang settings: ${err.message}`;
});
}
@@ -311,7 +310,9 @@
function refreshItems(itemIds) {
const token = getClientToken();
const headers = token ? { "X-Emby-Token": token, "X-MediaBrowser-Token": token } : {};
const headers = token
? { Authorization: `MediaBrowser Token="${token}"`, "X-Emby-Token": token, "X-MediaBrowser-Token": token }
: {};
itemIds.forEach((itemId) => {
fetch(`${BASE_PATH}/Multilang/RefreshItem/${itemId}?includeChildren=true${token ? `&token=${encodeURIComponent(token)}` : ""}`, { method: "POST", headers });
});
@@ -376,6 +377,11 @@
if (!url.startsWith("/")) url = "/" + url;
if (BASE_PATH && !url.toLowerCase().startsWith(BASE_PATH.toLowerCase() + "/")) url = BASE_PATH + url;
}
const local = new URL(url, window.location.origin);
if (local.origin === window.location.origin && local.pathname.startsWith(BASE_PATH + "/Multilang/Assets/")) {
local.searchParams.set("api_key", getClientToken());
return local.href;
}
return url;
}
@@ -420,7 +426,10 @@
};
const observer = new MutationObserver((mutations) => mutations.forEach((m) => {
if (m.type === "childList") m.addedNodes.forEach(scanNode);
if (m.type === "childList") {
m.addedNodes.forEach(scanNode);
ensureUserRulesLink();
}
if (m.type === "attributes") rewriteElementImages(m.target);
}));
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "src", "data-src"] });
@@ -0,0 +1,98 @@
using System.Net;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class AssetStorageServiceTests
{
[Fact]
public async Task LocalStorageDownloadsAStableAssetPathAndServesIt()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var factory = new TestHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent([1, 2, 3])
});
var service = new AssetStorageService(factory, store);
var path = await service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None);
Assert.NotNull(path);
Assert.StartsWith(TranslationStore.LocalAssetUrlPrefix, path, StringComparison.Ordinal);
Assert.True(store.TryNormalizeLocalAssetPath(path, out var fullPath));
Assert.Equal([1, 2, 3], await File.ReadAllBytesAsync(fullPath));
Assert.True(service.TryResolveLocalAsset(path[TranslationStore.LocalAssetUrlPrefix.Length..], out var resolved, out var contentType));
Assert.Equal(fullPath, resolved);
Assert.Equal("image/jpeg", contentType);
store.UpsertAsset("item", "fi", "poster", path, 1);
var original = await service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None);
store.UpsertAsset("item", "Original", "poster", original!, 1);
Assert.Equal(path, original);
Assert.Single(factory.Requests);
Assert.True(store.IsAssetReferenced("item", path));
Assert.True(File.Exists(fullPath));
File.Delete(fullPath);
Assert.False(File.Exists(fullPath));
await service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None);
Assert.True(File.Exists(fullPath));
Assert.Equal(2, factory.Requests.Count);
}
finally
{
TestPaths.DeleteRoot(root);
}
}
[Fact]
public async Task UrlStorageDoesNotFetchOrWriteAnAsset()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var factory = new TestHttpClientFactory(_ => throw new InvalidOperationException("URL storage must not fetch."));
var service = new AssetStorageService(factory, store);
const string url = "https://images.example/poster.webp";
var result = await service.StoreAsync("item", url, false, CancellationToken.None);
Assert.Equal(url, result);
Assert.Empty(factory.Requests);
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
}
finally
{
TestPaths.DeleteRoot(root);
}
}
[Fact]
public async Task LocalStorageRejectsAnOversizedResponseBeforeWritingIt()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var factory = new TestHttpClientFactory(_ =>
{
var content = new ByteArrayContent([1]);
content.Headers.ContentLength = 51L * 1024L * 1024L;
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
});
var service = new AssetStorageService(factory, store);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
}
finally
{
TestPaths.DeleteRoot(root);
}
}
}
@@ -6,6 +6,24 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class CategoryRuleEvaluatorTests
{
[Fact]
public void FastMatchingAndDiagnosticTraceAgreeAcrossAllRuleModes()
{
foreach (var relation in new[] { "is", "is_not", "contains", "not_contains" })
foreach (var fieldOr in new[] { false, true })
foreach (var valueOr in new[] { false, true })
foreach (var matchAll in new[] { false, true })
foreach (var facts in new[] { Facts(originalLanguage: "sv"), Facts(spokenLanguages: ["fi", "en"]), Facts(kind: "tv") })
{
var rule = Rule("original_language", relation, valueOr, "sv", "fi");
rule.Requirements[0].Fields = ["original_language", "spoken_languages"];
rule.Requirements[0].UseFieldOr = fieldOr;
rule.Requirements = [rule.Requirements[0], Requirement("production_countries", "contains", false, "FI")];
rule.MatchAllConditions = matchAll;
Assert.Equal(CategoryRuleEvaluator.Trace(rule, facts).Result, CategoryRuleEvaluator.Matches(rule, facts));
}
}
[Fact]
public void IsWithAndListRequiresExactSet()
{
@@ -95,11 +113,9 @@ public sealed class CategoryRuleEvaluatorTests
{
Id = rule.Id,
Label = rule.Label,
CriteriaText = rule.CriteriaText,
Requirements = rule.Requirements,
MatchAllConditions = matchAll,
Scopes = rule.Scopes,
FieldActions = rule.FieldActions,
FieldActionLists = rule.FieldActionLists
};
@@ -0,0 +1,232 @@
using System.Net;
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemFetcherTests : IDisposable
{
private readonly string _root = TestPaths.CreateRoot();
private readonly TranslationStore _store;
private readonly TestHttpClientFactory _http;
private readonly ItemFetcher _fetcher;
private readonly RefreshItemInfo _item = new("11111111111111111111111111111111", "1", "movie", RefreshWorkClass.Movies, 0, 0, null, "Movie");
private readonly PluginConfiguration _config = new()
{
Languages = ["en", "fi"],
TmdbApiKey = "test",
Providers = [new() { Id = "tmdb", MetadataOrder = 0 }]
};
private string _original = "en";
private string _overview = "Overview";
private bool _missingFinnishTagline;
private bool _notFound;
private bool _missingTmdbPoster;
private bool _fanartUnauthorized;
public ItemFetcherTests()
{
_store = new(new TestApplicationPaths(_root));
_http = new(request =>
{
if (_notFound) return new(HttpStatusCode.NotFound);
var url = request.RequestUri!;
if (url.Host == "webservice.fanart.tv")
return _fanartUnauthorized ? new(HttpStatusCode.Unauthorized)
: Json(new { movieposter = new[] { new { lang = "en", url = "https://assets.fanart.tv/poster.jpg", likes = "5" } } });
if (url.Host == "image.tmdb.org")
return new(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3]) };
if (url.AbsolutePath.EndsWith("/images"))
return Json(new { posters = _missingTmdbPoster ? [] : new[] { new { iso_639_1 = "en", file_path = "/poster.jpg" } }, logos = Array.Empty<object>(), backdrops = Array.Empty<object>() });
var language = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(url.Query)["language"].ToString();
return Json(new
{
title = language + " title",
overview = _overview,
tagline = language == "fi" && _missingFinnishTagline ? "" : "Tagline",
original_language = _original,
original_title = "Original title",
genres = Array.Empty<object>()
});
});
_fetcher = new(_store, new(_http), new(_http), new(_http, _store), NullLogger<ItemFetcher>.Instance);
}
private static HttpResponseMessage Json(object value)
=> new(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(value)) };
private Task<bool> Fetch(RefreshJobType job = RefreshJobType.Missing) => _fetcher.RefreshAsync(_item, _config, job, CancellationToken.None);
private string Text(string language, string field) => _store.GetTranslations([_item.ItemId], [language])[_item.ItemId][language][field];
[Fact]
public async Task CompleteMetadataIsNotRefetchedAndOriginalReusesConfiguredLanguage()
{
Assert.True(await Fetch());
Assert.Equal(2, _http.Requests.Count);
Assert.Equal(Text("en", "title"), Text("Original", "title"));
Assert.False(_fetcher.NeedsRefresh(_item, _config));
Assert.False(await Fetch());
Assert.Equal(2, _http.Requests.Count);
await Fetch(RefreshJobType.Full);
Assert.Equal(4, _http.Requests.Count);
}
[Fact]
public async Task MissingRetryFetchesOnlyIncompleteLanguageAndUpdatesWholeResponse()
{
_missingFinnishTagline = true;
await Fetch();
Assert.False(_fetcher.NeedsRefresh(_item, _config));
await Fetch();
Assert.Equal(2, _http.Requests.Count);
_config.WaitDaysForMissingData = 0;
Assert.True(_fetcher.NeedsRefresh(_item, _config));
_missingFinnishTagline = false;
_overview = "Updated overview";
await Fetch();
Assert.Equal(3, _http.Requests.Count);
Assert.Contains("language=fi", _http.Requests.Last());
Assert.Equal("Updated overview", Text("fi", "overview"));
Assert.Equal("Overview", Text("en", "overview"));
Assert.False(_fetcher.NeedsRefresh(_item, _config));
}
[Fact]
public async Task AdditionalOriginalLanguageIsFetchedOnceAndNewConfiguredLanguageDoesNotRefetchOthers()
{
_original = "sv";
await Fetch();
Assert.Equal(3, _http.Requests.Count);
Assert.Equal("sv title", Text("Original", "title"));
await Fetch();
Assert.Equal(3, _http.Requests.Count);
_config.Languages = ["en", "fi", "fr"];
await Fetch();
Assert.Equal(4, _http.Requests.Count);
Assert.Contains("language=fr", _http.Requests.Last());
}
[Fact]
public async Task NotFoundIsRememberedUntilRetryCutoff()
{
_notFound = true;
Assert.False(await Fetch());
Assert.False(_fetcher.NeedsRefresh(_item, _config));
await Fetch();
Assert.Equal(2, _http.Requests.Count);
_config.WaitDaysForMissingData = 0;
Assert.True(_fetcher.NeedsRefresh(_item, _config));
await Fetch();
Assert.Equal(4, _http.Requests.Count);
}
[Fact]
public async Task LocalRepairDoesNotFetchMetadataAndUrlCleanupOnlyHappensInScheduledHousekeeping()
{
_config.Providers[0].ArtworkOrder = 0;
await Fetch();
Assert.Equal(3, _http.Requests.Count);
_config.AssetStorageMode = "local";
Assert.True(_fetcher.NeedsLocalWork(_item.ItemId, _config));
await Fetch(RefreshJobType.Aggregate);
Assert.Equal(4, _http.Requests.Count);
Assert.Contains("image.tmdb.org", _http.Requests.Last());
var path = _store.GetAssetPath(_item.ItemId, "en", "poster")!;
Assert.Equal(path, _store.GetAssetPath(_item.ItemId, "Original", "poster"));
Assert.True(_store.TryNormalizeLocalAssetPath(path, out var file));
File.Delete(file);
await Fetch();
Assert.Equal(5, _http.Requests.Count);
Assert.True(File.Exists(file));
_config.AssetStorageMode = "url";
await Fetch();
Assert.Equal(path, _store.GetAssetPath(_item.ItemId, "en", "poster"));
Assert.True(File.Exists(file));
_store.CleanupForConfiguration(new HashSet<string> { _item.ItemId }, _config.Languages, false);
Assert.StartsWith("https://", _store.GetAssetPath(_item.ItemId, "en", "poster"));
Assert.False(File.Exists(file));
Assert.Equal(5, _http.Requests.Count);
}
[Fact]
public async Task ArtworkUsesProviderPriorityAndFallsBackWhenHigherPriorityImageDisappears()
{
_config.Providers = [new() { Id = "tmdb", MetadataOrder = 0, ArtworkOrder = 0 }, new() { Id = "fanart", ArtworkOrder = 1 }];
_config.FanartApiKey = "test";
await Fetch();
Assert.Contains("image.tmdb.org", _store.GetAssetPath(_item.ItemId, "en", "poster"));
_missingTmdbPoster = true;
await Fetch(RefreshJobType.Full);
Assert.Contains("assets.fanart.tv", _store.GetAssetPath(_item.ItemId, "en", "poster"));
_fanartUnauthorized = true;
await Assert.ThrowsAsync<HttpRequestException>(() => Fetch(RefreshJobType.Full));
Assert.Contains("assets.fanart.tv", _store.GetAssetPath(_item.ItemId, "en", "poster"));
}
[Fact]
public async Task BackupPreservesSourceUrlsAndFetchStateAndFiltersUnwantedLanguages()
{
_config.Providers[0].ArtworkOrder = 0;
await Fetch();
var backup = Path.Combine(_root, "export.sqlite");
_store.ExportTranslationDatabase(backup);
_store.ResetAll();
_store.ImportTranslationDatabase(backup, new HashSet<string> { _item.ItemId }, new HashSet<string> { "en" });
Assert.Equal(2, _store.GetFetchStates(_item.ItemId).Count);
Assert.DoesNotContain("metadata:fi", _store.GetFetchStates(_item.ItemId).Keys);
Assert.Equal("https://image.tmdb.org/t/p/original/poster.jpg", _store.GetStoredAssets(_item.ItemId).First().SourceUrl);
Assert.Equal("tmdb", _store.GetStoredAssets(_item.ItemId).First().Provider);
}
[Theory]
[InlineData(true, false, false)]
[InlineData(true, true, true)]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
public void YoungFetchStateHonorsKnownMissingVersusDeletedData(bool complete, bool missing, bool expected)
=> Assert.Equal(expected, ItemFetcher.NeedsFetch(new("scope", 100, complete), "scope", missing, false, 50));
[Fact]
public void FailedArtworkPacketRollsBackDeletionAndNewRowsTogether()
{
var original = new StoredAsset("en", "poster", "https://example.test/old.jpg", "https://example.test/old.jpg", "tmdb");
_store.SaveArtwork(_item.ItemId, [original], new Dictionary<string, FetchState>());
Assert.Throws<Microsoft.Data.Sqlite.SqliteException>(() => _store.SaveArtwork(_item.ItemId,
[original with { Path = "new" }, original], new Dictionary<string, FetchState>()));
Assert.Equal(original, Assert.Single(_store.GetStoredAssets(_item.ItemId)));
}
[Fact]
public async Task ParallelMetadataPacketsDoNotLoseRows()
{
await Task.WhenAll(Enumerable.Range(0, 12).Select(index => Task.Run(() =>
_store.SaveMetadata(_item with { ItemId = index.ToString() }, "en",
new("Title", "Overview", "Tagline", "Title", "en", [], [], [], []),
new("scope", 1, true), true))));
foreach (var index in Enumerable.Range(0, 12))
{
Assert.NotNull(_store.GetFacts(index.ToString()));
Assert.Equal(3, _store.GetTranslations([index.ToString()], ["en"])[index.ToString()]["en"].Count);
Assert.True(_store.GetFetchStates(index.ToString())["metadata:en"].Complete);
}
}
[Fact]
public void CollectionAggregationKeepsOnlySharedSingleLanguageAndUnionsCountrySets()
{
var collection = new FactsData("collection", "1", "collection", "Collection", "en", "en", "[]", "[]", "[]", "", "[]", 1, 1);
var finnish = collection with { Kind = "movie", OriginalLanguage = "fi", ProductionCountriesJson = "[\"FI\"]" };
var swedish = finnish with { OriginalLanguage = "sv", ProductionCountriesJson = "[\"SE\",\"FI\"]" };
var mixed = Services.ItemsProxyTransformer.AggregateCollectionFacts(collection, [finnish, swedish]);
Assert.Equal("", mixed.OriginalLanguage);
Assert.Equal("fi,sv", mixed.OriginalLanguageAll);
Assert.Equal(new HashSet<string> { "FI", "SE" }, JsonSerializer.Deserialize<HashSet<string>>(mixed.ProductionCountriesJson));
Assert.Equal("fi", Services.ItemsProxyTransformer.AggregateCollectionFacts(collection, [finnish, finnish]).OriginalLanguage);
}
public void Dispose() => TestPaths.DeleteRoot(_root);
}
@@ -0,0 +1,132 @@
using Jellyfin.Plugin.Multilang.Services;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxyCacheTests
{
[Fact]
public void PolicyChangesInvalidateCachedDataAndOldInFlightWrites()
{
var cache = new ItemsProxyCache();
var generation = cache.ObserveUserPolicy("user", "all libraries");
Store(cache, "key");
Assert.Equal(generation, cache.ObserveUserPolicy("user", "all libraries"));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.ObserveUserPolicy("user", "no libraries") > generation);
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
Assert.False(cache.Store("key", "user", "/Items", 1, "body", "application/json", 1, 100, TimeSpan.FromMinutes(1), generation));
}
[Fact]
public void StoreRetrievesAndClearsCachedResponses()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "key"));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
Assert.Equal("body-key", response.Body);
Assert.Equal(1, response.ItemCount);
cache.ClearAll();
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void MicroCacheServesFastResponsesAndIsInvalidatedWithTheMainCache()
{
var cache = new ItemsProxyCache();
Assert.True(cache.StoreMicro("key", "user", "/Items", 1, "micro", "application/json", 1, 1024));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
Assert.Equal("micro", response.Body);
cache.ClearAll();
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void StoreEvictsTheOldestEntryWhenCapacityIsExceeded()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "first", body: "1111", maxBytes: 6));
Thread.Sleep(10);
Assert.True(Store(cache, "second", body: "2222", maxBytes: 6));
Assert.False(cache.TryGet("first", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("second", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void StoreEntriesExpireAtTheConfiguredTtl()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "key", ttl: TimeSpan.FromMilliseconds(1)));
Thread.Sleep(20);
Assert.False(cache.TryGet("key", TimeSpan.FromMilliseconds(1), out _));
}
[Fact]
public void StoreRejectsResponsesLargerThanTheConfiguredLimit()
{
var cache = new ItemsProxyCache();
Assert.False(Store(cache, "key", body: "12345", maxBytes: 4));
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
private static bool Store(
ItemsProxyCache cache,
string key,
int itemCount = 1,
string? body = null,
long maxBytes = 1024,
TimeSpan? ttl = null)
=> cache.Store(
key,
"user",
"/Items",
itemCount,
body ?? "body-" + key,
"application/json",
100,
maxBytes,
ttl ?? TimeSpan.FromMinutes(1));
[Fact]
public void MicroAndMainEntriesShareOneBudget()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "main", body: "1111", maxBytes: 6));
Assert.True(cache.StoreMicro("micro1", "user", "/Items", 1, "2222", "application/json", 1, 6));
Assert.False(cache.TryGet("main", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.StoreMicro("micro2", "user", "/Items", 1, "3333", "application/json", 1, 6));
Assert.False(cache.TryGet("micro1", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("micro2", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void InvalidationRejectsStaleInFlightWrites()
{
var cache = new ItemsProxyCache();
var generation = cache.Generation;
cache.ClearAll();
Assert.False(cache.Store("key", "user", "/Items", 1, "body", "application/json", 1, 100, TimeSpan.FromMinutes(1), generation));
Assert.False(cache.StoreMicro("key", "user", "/Items", 1, "body", "application/json", 1, 100, generation));
}
[Fact]
public void ClearingOneUserPreservesOtherUsersCachedResponses()
{
var cache = new ItemsProxyCache();
Store(cache, "one");
cache.Store("two", "other", "/Items", 1, "body", "application/json", 1, 100, TimeSpan.FromMinutes(1));
cache.ClearUser("user");
Assert.False(cache.TryGet("one", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("two", TimeSpan.FromMinutes(1), out _));
}
}
@@ -16,14 +16,13 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?Recursive=true&api_key=old",
"token",
UserId,
multilangEnabled: true);
Assert.NotNull(result);
Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath);
Assert.Contains("api_key=token", result.Upstream.Query);
Assert.DoesNotContain("api_key=old", result.Upstream.Query);
Assert.DoesNotContain("api_key", result.Upstream.Query, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("api_key", result.NormalizedUrlForCache, StringComparison.OrdinalIgnoreCase);
Assert.Contains("userId=" + UserId, result.NormalizedUrlForCache);
}
@@ -36,7 +35,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
$"/Users/{OtherUserId}/Items?Limit=5",
"token",
UserId,
multilangEnabled: true);
@@ -52,7 +50,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L",
"token",
UserId,
multilangEnabled: true);
@@ -65,7 +62,29 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Equal("fi-FI", result.Controls.ClientLocale);
Assert.DoesNotContain("SortBy=", result.Upstream.Query);
Assert.DoesNotContain("Limit=", result.Upstream.Query);
Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey);
Assert.DoesNotContain("SortBy=", result.CacheKey);
}
[Fact]
public void BuildUsesOneUpstreamKeyForDifferentLocalPagingControls()
{
var request = HttpRequest();
var first = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&StartIndex=0&Limit=100",
UserId,
multilangEnabled: true);
var second = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&StartIndex=100&Limit=100",
UserId,
multilangEnabled: true);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache);
Assert.Equal(first.CacheKey, second.CacheKey);
}
[Fact]
@@ -76,7 +95,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&Limit=10",
"token",
UserId,
multilangEnabled: false);
@@ -95,7 +113,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"http://other-host:8096/Items",
"token",
UserId,
multilangEnabled: true);
@@ -108,6 +125,18 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Equal([1, 2], ItemsProxyRequestBuilder.ParseLocalGenreIds("tmdb-1,abc,2,tmdb-2,tmdb-x,1"));
}
[Fact]
public void GenreFilteringRetainsTheFieldsNeededForLocalFiltering()
{
var result = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?GenreIds=tmdb-18&Fields=Overview", UserId, true);
Assert.NotNull(result);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(result.Upstream.Query);
Assert.False(query.ContainsKey("GenreIds"));
Assert.Equal(1, query["Fields"].Count);
Assert.Contains("GenreItems", query["Fields"].ToString());
Assert.Contains("Overview", query["Fields"].ToString());
}
private static HttpRequest HttpRequest(string query = "")
{
var context = new DefaultHttpContext();
@@ -116,4 +145,40 @@ public sealed class ItemsProxyRequestBuilderTests
context.Request.QueryString = new QueryString(query);
return context.Request;
}
[Theory]
[InlineData("//other-host:8096/Items")]
[InlineData("/\\other-host/Items")]
[InlineData("https://server.local:8096/Items")]
[InlineData("/Multilang/ItemsProxy")]
[InlineData("/System/Configuration")]
[InlineData("/Items/Filters")]
[InlineData("/Items/../System/Info")]
public void RejectsUnsupportedDestinations(string url)
=> Assert.Null(ItemsProxyRequestBuilder.Build(HttpRequest(), url, UserId, true));
[Fact]
public void EquivalentQueryOrderSharesCacheAndInFlightKeys()
{
var first = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?Fields=Overview&Recursive=true", UserId, true)!;
var second = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?Recursive=true&Fields=Overview", UserId, true)!;
Assert.Equal(first.CacheKey, second.CacheKey);
Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache);
}
[Fact]
public void BasePathAndDashedUserIdsAreNormalized()
{
var request = HttpRequest();
request.PathBase = "/jellyfin";
var result = ItemsProxyRequestBuilder.Build(request, "/jellyfin/Users/22222222-2222-2222-2222-222222222222/Items", UserId, true)!;
Assert.Equal($"/jellyfin/Users/{UserId}/Items", result.Upstream.AbsolutePath);
Assert.Null(ItemsProxyRequestBuilder.Build(request, "/Items", UserId, true));
}
[Theory]
[InlineData("/Items/Resume?StartIndex=100&Limit=20")]
[InlineData("/Shows/NextUp?StartIndex=100&Limit=20")]
public void BoundedListsKeepUpstreamPaging(string url)
=> Assert.False(ItemsProxyRequestBuilder.Build(HttpRequest(), url, UserId, true)!.Controls.ApplyLocalPaging);
}
@@ -0,0 +1,52 @@
using Jellyfin.Plugin.Multilang.Services;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxyRequestCoalescerTests
{
[Fact]
public async Task MatchingRequestsShareOneInFlightFetch()
{
var coalescer = new ItemsProxyRequestCoalescer();
var response = new TaskCompletionSource<ItemsProxyUpstreamResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
var fetches = 0;
Task<ItemsProxyUpstreamResponse> Fetch(CancellationToken _)
{
fetches++;
return response.Task;
}
var first = coalescer.GetOrFetchAsync("user|/Items", Fetch, CancellationToken.None);
var second = coalescer.GetOrFetchAsync("user|/Items", Fetch, CancellationToken.None);
Assert.Equal(1, fetches);
response.SetResult(new ItemsProxyUpstreamResponse(200, "{}", "application/json"));
var firstResult = await first;
var secondResult = await second;
Assert.False(firstResult.JoinedExistingRequest);
Assert.True(secondResult.JoinedExistingRequest);
Assert.Equal("{}", firstResult.Response.Body);
Assert.Equal(firstResult.Response, secondResult.Response);
}
[Fact]
public async Task CancellingOneWaiterDoesNotCancelTheSharedFetch()
{
var coalescer = new ItemsProxyRequestCoalescer();
var response = new TaskCompletionSource<ItemsProxyUpstreamResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
using var cancellation = new CancellationTokenSource();
var cancelledWaiter = coalescer.GetOrFetchAsync("user|/Items", _ => response.Task, cancellation.Token);
var activeWaiter = coalescer.GetOrFetchAsync("user|/Items", _ => response.Task, CancellationToken.None);
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelledWaiter);
response.SetResult(new ItemsProxyUpstreamResponse(200, "{}", "application/json"));
var result = await activeWaiter;
Assert.True(result.JoinedExistingRequest);
Assert.Equal(200, result.Response.StatusCode);
}
}
@@ -8,7 +8,15 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxySortingTests
{
[Fact]
public void GetSortArticlesUsesBuiltInLocaleFallback()
public void TranslationOnlyDoesNotRewriteUpstreamPaging()
{
var root = JsonNode.Parse("""{"Items":[{"Name":"One"},{"Name":"Two"}],"TotalRecordCount":200,"StartIndex":100}""")!;
var original = root.ToJsonString();
ItemsProxySorting.Apply(root, new ItemsProxyControls("", "", "", 0, 0, "", "", false), CultureInfo.InvariantCulture, []);
Assert.Equal(original, root.ToJsonString());
}
[Fact]
public void GetSortArticlesUsesBuiltInLanguageFallback()
{
var articles = ItemsProxySorting.GetSortArticles(new SortArticleCatalog(), [], "sv-FI");
@@ -27,7 +35,7 @@ public sealed class ItemsProxySortingTests
}
[Fact]
public void GetSortArticlesPrefersConfiguredExactLocale()
public void GetSortArticlesPrefersConfiguredExactLanguage()
{
var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(),
@@ -40,6 +48,32 @@ public sealed class ItemsProxySortingTests
Assert.Equal(["exact"], articles);
}
[Fact]
public void GetSortArticlesIncludesAlwaysIgnoredArticlesFromOtherLanguages()
{
var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(),
[
new SortArticleEntry { Language = "en", Articles = "a, an, the", AlwaysApply = true },
new SortArticleEntry { Language = "it", Articles = "il, lo, la" }
],
"it");
Assert.Equal(["a", "an", "the", "il", "lo", "la"], articles);
}
[Fact]
public void GetSortArticlesReturnsNoneWhenArticleIgnoringIsDisabled()
{
var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(),
[new SortArticleEntry { Language = "en", Articles = "a, an, the", AlwaysApply = true }],
"en",
ignoreArticles: false);
Assert.Empty(articles);
}
[Fact]
public void ApplySortsByTitleAfterLeadingArticles()
{
@@ -54,6 +88,41 @@ public sealed class ItemsProxySortingTests
Assert.Equal(["Avatar", "A Beautiful Mind", "The Matrix"], ItemNames(root));
}
[Fact]
public void ApplyUsesEachTitlesLanguageForArticleRemoval()
{
var root = ItemsRoot("La La Land", "La vita e bella", "Terminator", "The Matrix");
var articles = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["La La Land"] = [],
["La vita e bella"] = ["il", "lo", "la"],
["Terminator"] = [],
["The Matrix"] = ["a", "an", "the"]
};
ItemsProxySorting.Apply(
root,
new ItemsProxyControls("SortName", "Ascending", "", 0, 0, "en-US", ""),
CultureInfo.GetCultureInfo("en-US"),
item => articles[item["Name"]!.GetValue<string>()]);
Assert.Equal(["La La Land", "The Matrix", "Terminator", "La vita e bella"], ItemNames(root));
}
[Fact]
public void ApplyStripsApostropheArticles()
{
var root = ItemsRoot("L'avventura", "The Matrix");
ItemsProxySorting.Apply(
root,
new ItemsProxyControls("SortName", "Ascending", "", 0, 0, "en-US", ""),
CultureInfo.GetCultureInfo("en-US"),
item => item["Name"]!.GetValue<string>() == "L'avventura" ? ["l'"] : ["the"]);
Assert.Equal(["L'avventura", "The Matrix"], ItemNames(root));
}
[Fact]
public void ApplyPadsNumbersDuringTitleSort()
{
@@ -13,6 +13,22 @@ public sealed class ItemsProxyTransformerResolutionTests
private const string OriginalAction = "Original";
private const string LanguagePrefix = "Language:";
[Fact]
public void TraceUsesStoredOriginalTranslationWhenOriginalTitleIsEmpty()
{
var facts = Facts(originalTitle: "", originalLanguage: "fi");
var translations = TextTranslations((OriginalAction, TitleField, "Alkuperainen nimi"));
var attempts = new List<ItemsProxyResolutionAttempt>();
var normal = ItemsProxyTransformer.ResolveField(TitleField, [OriginalAction], facts, translations);
var traced = ItemsProxyTransformer.ResolveField(TitleField, [OriginalAction], facts, translations, attempts.Add);
Assert.Equal(normal, traced);
Assert.Equal("fi", traced.Language);
var chosen = Assert.Single(attempts);
Assert.True(chosen.Chosen);
Assert.Equal("Original/title", chosen.LookupKey);
Assert.Equal(traced.Value, chosen.Value);
}
[Fact]
public void ResolveFieldUsesFirstLanguageWithData()
{
@@ -29,6 +45,7 @@ public sealed class ItemsProxyTransformerResolutionTests
Assert.True(result.Change);
Assert.Equal("Finnish overview", result.Value);
Assert.Equal("fi", result.Language);
}
[Fact]
@@ -37,11 +54,12 @@ public sealed class ItemsProxyTransformerResolutionTests
var result = ItemsProxyTransformer.ResolveField(
TitleField,
[OriginalAction, LanguagePrefix + "en"],
Facts(originalTitle: "Original title"),
Facts(originalTitle: "Original title", originalLanguage: "en"),
TextTranslations(("en", TitleField, "English title")));
Assert.True(result.Change);
Assert.Equal("Original title", result.Value);
Assert.Equal("en", result.Language);
}
[Fact]
@@ -55,6 +73,7 @@ public sealed class ItemsProxyTransformerResolutionTests
Assert.False(result.Change);
Assert.Null(result.Value);
Assert.Null(result.Language);
}
[Fact]
@@ -145,13 +164,13 @@ public sealed class ItemsProxyTransformerResolutionTests
return result;
}
private static FactsData Facts(string originalTitle = "")
private static FactsData Facts(string originalTitle = "", string originalLanguage = "")
=> new(
"item",
"tmdb",
"movie",
originalTitle,
"",
originalLanguage,
"",
"[]",
"[]",
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -13,7 +13,8 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Jellyfin.Common" Version="10.11.9" />
<PackageReference Include="Jellyfin.Common" Version="12.0.0" />
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Net;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Providers;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class MaintenanceAndProviderTests
{
[Fact]
public async Task ConcurrentRefreshesCanOverlapButMaintenanceWaitsForAllOfThem()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var first = await store.EnterRefreshAsync(CancellationToken.None);
var second = await store.EnterRefreshAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
using var cancel = new CancellationTokenSource();
var maintenance = store.EnterMaintenanceAsync(cancel.Token);
Assert.False(maintenance.IsCompleted);
first.Dispose();
Assert.False(maintenance.IsCompleted);
cancel.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => maintenance);
second.Dispose();
using var allSlots = await store.EnterMaintenanceAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
}
finally { TestPaths.DeleteRoot(root); }
}
[Fact]
public async Task MaintenanceWaitsForWriterAndCanceledWaiterDoesNotLeakLease()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var writer = await store.EnterMaintenanceAsync(CancellationToken.None);
using var canceled = new CancellationTokenSource();
var waiting = store.EnterMaintenanceAsync(canceled.Token);
Assert.False(waiting.IsCompleted);
canceled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waiting);
var cleanup = store.EnterMaintenanceAsync(CancellationToken.None);
Assert.False(cleanup.IsCompleted);
writer.Dispose();
using var lease = await cleanup.WaitAsync(TimeSpan.FromSeconds(5));
}
finally { TestPaths.DeleteRoot(root); }
}
[Fact]
public void ResetKeepsExistingConnectionsOnTheSameDatabase()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
store.SaveUserRules("user", new UserRulesDocument());
using var connection = store.Open();
connection.Open();
using var query = connection.CreateCommand();
query.CommandText = "SELECT COUNT(*) FROM user_rules;";
Assert.Equal(1L, query.ExecuteScalar());
store.ResetAll();
Assert.Equal(0L, query.ExecuteScalar());
}
finally { TestPaths.DeleteRoot(root); }
}
[Fact]
public async Task ProviderDoesNotRetryProgrammingErrors()
{
var attempts = 0;
using var http = new HttpClient(new TestHttpMessageHandler(_ =>
{
attempts++;
throw new InvalidOperationException("Programming error");
}));
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/");
await Assert.ThrowsAsync<InvalidOperationException>(() => ProviderHttp.SendWithRetryAsync(http, request, CancellationToken.None));
Assert.Equal(1, attempts);
}
[Fact]
public async Task ConcurrentProviderAdmissionsKeepTheirSpacing()
{
var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(50));
var starts = await Task.WhenAll(Enumerable.Range(0, 4).Select(async _ =>
{
await limiter.WaitAsync(CancellationToken.None);
return Stopwatch.GetTimestamp();
}));
Array.Sort(starts);
Assert.All(starts.Zip(starts.Skip(1)), pair =>
Assert.True(Stopwatch.GetElapsedTime(pair.First, pair.Second) >= TimeSpan.FromMilliseconds(40)));
}
}
@@ -0,0 +1,74 @@
using System.IO.Compression;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Backup;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class MultilangBackupServiceTests
{
[Fact]
public void FullExportIncludesTheSelectedSections()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
store.SaveUserRules(UserId, new UserRulesDocument { Enabled = true, SortLocale = "fi-FI" });
var service = new MultilangBackupService(store, null!, null!);
var bytes = service.Export(
new BackupExportOptions(PluginSettings: true, UserSettings: true, TranslationsDatabase: true, DownloadedAssets: false),
new PluginConfiguration { Languages = ["en", "fi"] });
using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read);
Assert.NotNull(zip.GetEntry("manifest.json"));
Assert.NotNull(zip.GetEntry("plugin-config.json"));
Assert.NotNull(zip.GetEntry("translations.sqlite"));
Assert.NotNull(zip.GetEntry("user-rules/" + UserId + ".json"));
Assert.Null(zip.GetEntry("assets/"));
}
finally
{
TestPaths.DeleteRoot(root);
}
}
[Fact]
public void UserExportCanBeInspectedAndImportedForAnotherUser()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var service = new MultilangBackupService(store, null!, null!);
var sourceRules = new UserRulesDocument
{
Enabled = true,
SortLocale = "sv-SE",
TrustTmdbCollections = false
};
var bytes = service.ExportUserRules(UserId, sourceRules);
var inspection = service.InspectUserImport(new MemoryStream(bytes), OtherUserId);
var result = service.ImportUser(new MemoryStream(bytes), OtherUserId, UserId);
Assert.Equal(1, inspection.UserSettingsCount);
Assert.False(inspection.ContainsCurrentUser);
Assert.Equal([UserId], inspection.UserIds);
Assert.Equal(1, result.UserSettingsImported);
var imported = store.GetUserRules(OtherUserId);
Assert.NotNull(imported);
Assert.True(imported.Enabled);
Assert.Equal("sv-SE", imported.SortLocale);
Assert.False(imported.TrustTmdbCollections);
}
finally
{
TestPaths.DeleteRoot(root);
}
}
private const string UserId = "11111111111111111111111111111111";
private const string OtherUserId = "22222222222222222222222222222222";
}
@@ -0,0 +1,114 @@
using System.Net;
using System.Text;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ProviderClientTests
{
[Fact]
public async Task TmdbMetadataParsesMovieFieldsAndDeduplicatesGenres()
{
var factory = JsonFactory("""
{
"title": "Finnish title",
"overview": "Overview",
"tagline": "Tagline",
"original_title": "Original title",
"original_language": "fi",
"origin_country": ["FI", "FI"],
"production_countries": [{"iso_3166_1":"FI"}],
"spoken_languages": [{"iso_639_1":"fi"}, {"iso_639_1":"en"}],
"genres": [{"id": 18, "name": "Drama"}, {"id": 18, "name": "Duplicate"}]
}
""");
var client = new TmdbClient(factory);
var result = await client.FetchMetadataAsync(Movie(), "fi-FI", "key", new FetchRateLimiter(TimeSpan.Zero), CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("Finnish title", result.Title);
Assert.Equal("Original title", result.OriginalTitle);
Assert.Equal("fi", result.OriginalLanguage);
Assert.Equal(["FI"], result.OriginCountries);
Assert.Equal(["fi", "en"], result.SpokenLanguages);
Assert.Equal([18], result.GenreIds);
Assert.Contains("language=fi-FI", factory.Requests.Single());
}
[Fact]
public async Task TmdbImagesKeepTheFirstImageForEachLanguage()
{
var factory = JsonFactory("""
{
"posters": [
{"iso_639_1":"fi", "file_path":"/first.jpg"},
{"iso_639_1":"fi", "file_path":"/second.jpg"},
{"iso_639_1":null, "file_path":"/none.jpg"}
],
"logos": [{"iso_639_1":"sv", "file_path":"/logo.png"}],
"backdrops": [{"iso_639_1":"en", "file_path":"/backdrop.jpg"}]
}
""");
var client = new TmdbClient(factory);
var result = await client.FetchImagesAsync(Movie(), "key", new FetchRateLimiter(TimeSpan.Zero), CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("https://image.tmdb.org/t/p/original/first.jpg", result.Posters["fi"]);
Assert.Equal("https://image.tmdb.org/t/p/original/logo.png", result.Logos["sv"]);
Assert.Equal("https://image.tmdb.org/t/p/original/backdrop.jpg", result.Backdrops["en"]);
}
[Fact]
public async Task TmdbNotFoundReturnsNoMetadata()
{
var factory = new TestHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var client = new TmdbClient(factory);
var result = await client.FetchMetadataAsync(Movie(), "en", "key", new FetchRateLimiter(TimeSpan.Zero), CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task FanartSelectsMostLikedArtworkAndUsesStandardLogoAsFallback()
{
var factory = JsonFactory("""
{
"movieposter": [
{"lang":"fi", "url":"https://images/fi-low.jpg", "likes":"2"},
{"lang":"fi", "url":"https://images/fi-best.jpg", "likes":"8"},
{"lang":"00", "url":"https://images/no-language.jpg", "likes":"99"}
],
"hdmovielogo": [{"lang":"sv", "url":"https://images/hd-logo.png", "likes":1}],
"movielogo": [
{"lang":"sv", "url":"https://images/standard-sv.png", "likes":9},
{"lang":"fi", "url":"https://images/standard-fi.png", "likes":3}
],
"moviebanner": [{"lang":"fi", "url":"https://images/banner.jpg", "likes":0}],
"moviethumb": [{"lang":"fi", "url":"https://images/thumb.jpg", "likes":0}]
}
""");
var client = new FanartClient(factory);
var result = await client.FetchMovieImagesAsync("123", "key", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("https://images/fi-best.jpg", result.Posters["fi"]);
Assert.Equal("https://images/hd-logo.png", result.Logos["sv"]);
Assert.Equal("https://images/standard-fi.png", result.Logos["fi"]);
Assert.Equal("https://images/banner.jpg", result.Banners["fi"]);
Assert.Equal("https://images/thumb.jpg", result.Thumbs["fi"]);
}
private static RefreshItemInfo Movie()
=> new("11111111111111111111111111111111", "123", "movie", RefreshWorkClass.Movies, 0, 0, null, "Movie");
private static TestHttpClientFactory JsonFactory(string json)
=> new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
@@ -0,0 +1,151 @@
using Jellyfin.Plugin.Multilang.Services;
using Jellyfin.Plugin.Multilang.Services.Refresh;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class RefreshCoordinatorTests
{
[Fact]
public async Task IndependentItemsOverlapWithinWorkerLimitAndDuplicateWorkIsShared()
{
using var coordinator = new RefreshCoordinator(concurrency: 2);
var twoStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0;
Task<bool> Queue(string id) => coordinator.EnqueueAsync(id, RefreshSourceTier.Manual, RefreshWorkClass.Movies,
RefreshJobType.Full, async (_, _) =>
{
if (Interlocked.Increment(ref count) == 2) twoStarted.SetResult();
await release.Task;
return true;
}, CancellationToken.None);
var first = Queue("first");
var second = Queue("second");
await twoStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Same(first, Queue("first"));
var third = Queue("third");
Assert.Equal(2, count);
Assert.Equal(2, coordinator.GetDiagnostics().ActiveItems.Length);
release.SetResult();
await Task.WhenAll(first, second, third).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(3, count);
}
[Fact]
public async Task ActiveMissingRefreshRunsFullUpgradeBeforeCompleting()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var fullRan = false;
var first = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Missing, async (_, _) =>
{
started.SetResult();
await release.Task;
return false;
}, CancellationToken.None);
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
var full = coordinator.EnqueueAsync("item", RefreshSourceTier.Manual, RefreshWorkClass.Movies, RefreshJobType.Full, (_, _) =>
{
fullRan = true;
return Task.FromResult(true);
}, CancellationToken.None);
release.SetResult();
Assert.True(await full.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.True(fullRan);
Assert.True(await first);
}
[Fact]
public async Task CanceledWaiterDoesNotCancelAnotherWaiter()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
using var cancel = new CancellationTokenSource();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var canceled = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full,
async (_, _) => { await release.Task; return true; }, cancel.Token);
var survivor = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full,
(_, _) => Task.FromResult(false), CancellationToken.None);
cancel.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => canceled);
release.SetResult();
Assert.True(await survivor.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task QueuedItemsUseSourceTierThenWorkClassPriority()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var order = new List<string>();
var active = coordinator.EnqueueAsync("active", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Full, async (_, _) =>
{
activeStarted.SetResult();
await releaseActive.Task;
return true;
}, CancellationToken.None);
await activeStarted.Task;
var series = coordinator.EnqueueAsync("series", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Full, (_, _) =>
{
order.Add("series");
return Task.FromResult(true);
}, CancellationToken.None);
var movie = coordinator.EnqueueAsync("movie", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full, (_, _) =>
{
order.Add("movie");
return Task.FromResult(true);
}, CancellationToken.None);
var onTheFlyCollection = coordinator.EnqueueAsync("collection", RefreshSourceTier.OnTheFly, RefreshWorkClass.Collections, RefreshJobType.Full, (_, _) =>
{
order.Add("collection");
return Task.FromResult(true);
}, CancellationToken.None);
releaseActive.SetResult();
await Task.WhenAll(active, series, movie, onTheFlyCollection);
Assert.Equal(["collection", "movie", "series"], order);
}
[Fact]
public async Task DuplicateQueuedItemUsesHighestJobAndTierWithoutInvalidatingUpstreamData()
{
var cache = new ItemsProxyCache();
Assert.True(cache.Store("target", "user", "/Items", 1, "body", "application/json", 1, 1024, TimeSpan.FromMinutes(1)));
using var coordinator = new RefreshCoordinator(concurrency: 1);
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var runs = new List<RefreshJobType>();
var active = coordinator.EnqueueAsync("active", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Full, async (_, _) =>
{
activeStarted.SetResult();
await releaseActive.Task;
return true;
}, CancellationToken.None);
await activeStarted.Task;
var missing = coordinator.EnqueueAsync("target", RefreshSourceTier.Background, RefreshWorkClass.Series, RefreshJobType.Missing, (job, _) =>
{
runs.Add(job);
return Task.FromResult(true);
}, CancellationToken.None);
var full = coordinator.EnqueueAsync("target", RefreshSourceTier.Manual, RefreshWorkClass.Series, RefreshJobType.Full, (job, _) =>
{
runs.Add(job);
return Task.FromResult(true);
}, CancellationToken.None);
Assert.Same(missing, full);
releaseActive.SetResult();
await Task.WhenAll(active, full);
Assert.Equal([RefreshJobType.Full], runs);
Assert.True(cache.TryGet("target", TimeSpan.FromMinutes(1), out _));
var recent = coordinator.GetDiagnostics().Recent.Single(item => item.ItemId == "target");
Assert.Equal("Manual", recent.SourceTier);
Assert.Equal("Full", recent.JobType);
}
}
@@ -0,0 +1,64 @@
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Plugin.Multilang.Tests;
internal sealed class TestApplicationPaths(string root) : IApplicationPaths
{
public string ProgramDataPath { get; } = root;
public string WebPath { get; } = Path.Combine(root, "web");
public string ProgramSystemPath { get; } = Path.Combine(root, "system");
public string DataPath { get; } = Path.Combine(root, "data");
public string ImageCachePath { get; } = Path.Combine(root, "images");
public string PluginsPath { get; } = Path.Combine(root, "plugins");
public string PluginConfigurationsPath { get; } = Path.Combine(root, "plugin-configs");
public string LogDirectoryPath { get; } = Path.Combine(root, "logs");
public string ConfigurationDirectoryPath { get; } = Path.Combine(root, "config");
public string SystemConfigurationFilePath { get; } = Path.Combine(root, "config", "system.xml");
public string CachePath { get; } = Path.Combine(root, "cache");
public string TempDirectory { get; } = Path.Combine(root, "temp");
public string VirtualDataPath { get; } = "%AppDataPath%";
public string TrickplayPath { get; } = Path.Combine(root, "trickplay");
public string BackupPath { get; } = Path.Combine(root, "backups");
public void MakeSanityCheckOrThrow()
{
}
public void CreateAndCheckMarker(string path, string marker, bool isTemporary = false)
{
}
}
internal sealed class TestHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : IHttpClientFactory
{
public List<string> Requests { get; } = [];
public HttpClient CreateClient(string name)
=> new(new TestHttpMessageHandler(request =>
{
Requests.Add(request.RequestUri?.ToString() ?? string.Empty);
return responseFactory(request);
}));
}
internal sealed class TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(responseFactory(request));
}
internal static class TestPaths
{
public static string CreateRoot()
{
var path = Path.Combine(Path.GetTempPath(), "multilang-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
public static void DeleteRoot(string root)
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
@@ -0,0 +1,33 @@
using System.Diagnostics;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using Xunit.Abstractions;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class TranslationStoreBatchTests(ITestOutputHelper output)
{
[Fact]
public void BatchFactsLookupMatchesIndividualReads()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var ids = Enumerable.Range(0, 100).Select(index => index.ToString()).ToArray();
foreach (var id in ids)
store.SaveMetadata(new(id, id, "movie", RefreshWorkClass.Movies, 0, 0, null, "Movie"), "fi",
new("Title", "Overview", "Tagline", "Title", "fi", [], [], [], []), new("scope", 1, true), true);
var timer = Stopwatch.StartNew();
var individual = ids.Select(id => store.GetFacts(id)!).ToArray();
var separateMs = timer.Elapsed.TotalMilliseconds;
timer.Restart();
var batch = store.GetFactsForItems(ids.Concat(ids));
var batchMs = timer.Elapsed.TotalMilliseconds;
Assert.Equal(individual, ids.Select(id => batch[id]));
Assert.Equal(ids.Length, batch.Count);
output.WriteLine($"100 individual fact reads: {separateMs:F2} ms; one batched read: {batchMs:F2} ms. Timing is diagnostic, not a pass condition.");
}
finally { TestPaths.DeleteRoot(root); }
}
}
@@ -0,0 +1,72 @@
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class TranslationStoreCleanupTests
{
[Fact]
public void CleanupForConfigurationRemovesStaleLanguagesItemsAndFiles()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
const string live = "11111111111111111111111111111111";
const string stale = "22222222222222222222222222222222";
SeedFacts(store, live);
SeedFacts(store, stale);
store.UpsertTranslation(live, "en", "title", "English");
store.UpsertTranslation(live, "fi", "title", "Finnish");
store.UpsertTranslation(stale, "en", "title", "Stale");
store.UpsertGenre(1, "movie", "en", "Action");
store.UpsertGenre(1, "movie", "fi", "Toiminta");
AddLocalAsset(store, live, "en", "poster");
AddLocalAsset(store, live, "fi", "poster");
AddLocalAsset(store, stale, "en", "poster");
var orphan = Path.Combine(store.AssetsDirectory, "orphan.jpg");
File.WriteAllText(orphan, "orphan");
var result = store.CleanupForConfiguration(
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { live },
["en"],
localAssetStorage: false);
Assert.Equal(new LocalCleanupResult(TranslationsDeleted: 1, AssetsDeleted: 2, GenresDeleted: 1, AssetFilesDeleted: 4), result);
Assert.NotNull(store.GetFacts(live));
Assert.Null(store.GetFacts(stale));
Assert.Equal("English", Translation(store, live, "en", "title"));
Assert.Null(Translation(store, live, "fi", "title"));
Assert.Null(store.GetAssetPath(live, "en", "poster"));
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
}
finally
{
TestPaths.DeleteRoot(root);
}
}
private static void SeedFacts(TranslationStore store, string itemId)
=> store.UpsertFacts(
new RefreshItemInfo(itemId, "1", "movie", RefreshWorkClass.Movies, 0, 0, null, "Movie"),
new TmdbMetadata("Title", "", "", "Title", "en", [], [], [], []),
1,
1);
private static void AddLocalAsset(TranslationStore store, string itemId, string lang, string kind)
{
var relative = Path.Combine(itemId, lang, kind + ".jpg");
var path = Path.Combine(store.AssetsDirectory, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, kind);
store.UpsertAsset(itemId, lang, kind, TranslationStore.LocalAssetUrlPrefix + relative.Replace('\\', '/'), 1);
}
private static string? Translation(TranslationStore store, string itemId, string lang, string field)
=> store.GetTranslations([itemId], [lang])
.GetValueOrDefault(itemId)?
.GetValueOrDefault(lang)?
.GetValueOrDefault(field);
}
@@ -1,7 +1,6 @@
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Plugin.Multilang.Tests;
@@ -10,7 +9,7 @@ public sealed class TranslationStoreImportTests
[Fact]
public void ImportTranslationDatabaseKeepsOnlyLiveItemsAndAllowedLanguages()
{
var root = Path.Combine(Path.GetTempPath(), "multilang-test-" + Guid.NewGuid().ToString("N"));
var root = TestPaths.CreateRoot();
try
{
var source = new TranslationStore(new TestApplicationPaths(Path.Combine(root, "source")));
@@ -38,8 +37,7 @@ public sealed class TranslationStoreImportTests
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
TestPaths.DeleteRoot(root);
}
}
@@ -69,31 +67,4 @@ public sealed class TranslationStoreImportTests
.GetValueOrDefault(itemId)?
.GetValueOrDefault(lang)?
.GetValueOrDefault(field);
private sealed class TestApplicationPaths(string root) : IApplicationPaths
{
public string ProgramDataPath { get; } = root;
public string WebPath { get; } = Path.Combine(root, "web");
public string ProgramSystemPath { get; } = Path.Combine(root, "system");
public string DataPath { get; } = Path.Combine(root, "data");
public string ImageCachePath { get; } = Path.Combine(root, "images");
public string PluginsPath { get; } = Path.Combine(root, "plugins");
public string PluginConfigurationsPath { get; } = Path.Combine(root, "plugin-configs");
public string LogDirectoryPath { get; } = Path.Combine(root, "logs");
public string ConfigurationDirectoryPath { get; } = Path.Combine(root, "config");
public string SystemConfigurationFilePath { get; } = Path.Combine(root, "config", "system.xml");
public string CachePath { get; } = Path.Combine(root, "cache");
public string TempDirectory { get; } = Path.Combine(root, "temp");
public string VirtualDataPath { get; } = "%AppDataPath%";
public string TrickplayPath { get; } = Path.Combine(root, "trickplay");
public string BackupPath { get; } = Path.Combine(root, "backups");
public void MakeSanityCheckOrThrow()
{
}
public void CreateAndCheckMarker(string path, string marker, bool isTemporary = false)
{
}
}
}
@@ -0,0 +1,61 @@
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Rules;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class UserRulesNormalizerTests
{
[Fact]
public void LegacyFieldsAreNotConvertedOrWrittenBack()
{
var rules = JsonSerializer.Deserialize<UserRulesDocument>("""
{"Categories":[{"CriteriaText":"original_language is fi",
"FieldActions":{"title":"Language:fi"},
"Requirements":[{"Field":"original_language","Relation":"is","Values":["fi"]}]}]}
""")!;
UserRulesNormalizer.Normalize(rules, ["fi"]);
var category = Assert.Single(rules.Categories);
Assert.Empty(category.Requirements);
Assert.Equal(["Jellyfin"], category.FieldActionLists["title"]);
var saved = JsonSerializer.Serialize(rules);
Assert.DoesNotContain("CriteriaText", saved);
Assert.DoesNotContain("\"Field\":", saved);
Assert.DoesNotContain("\"FieldActions\":", saved);
}
[Fact]
public void CurrentRulesPreserveBooleanModesAndNormalizeOnlyCompleteConditions()
{
var rules = new UserRulesDocument
{
Categories = [new()
{
Label = "Finnish", MatchAllConditions = false, Scopes = ["M", "C"],
Requirements = [new() { Fields = ["audio_language", "original_language", "origin_countries"],
UseFieldOr = true, Relation = "contains", UseOr = true, Values = ["fi", "sv"] }, new()],
FieldActionLists = new() { ["title"] = ["Language:FI", "Language:de", "Original"], ["overview"] = [] }
}]
};
UserRulesNormalizer.Normalize(rules, ["fi"]);
var category = Assert.Single(rules.Categories);
Assert.False(category.MatchAllConditions);
Assert.Equal(["M", "C"], category.Scopes);
var condition = Assert.Single(category.Requirements);
Assert.Equal(["original_language", "audio_language"], condition.Fields);
Assert.True(condition.UseFieldOr);
Assert.True(condition.UseOr);
Assert.Equal(["Language:fi", "Original", "Jellyfin"], category.FieldActionLists["title"]);
Assert.Empty(category.FieldActionLists["overview"]);
Assert.Equal(JsonSerializer.Serialize(rules), JsonSerializer.Serialize(UserRulesNormalizer.Normalize(rules, ["fi"])));
}
[Fact]
public void NullExternalCollectionsNormalizeToDefaults()
{
var rules = JsonSerializer.Deserialize<UserRulesDocument>("""{"Categories":null,"FallbackFieldActions":null}""")!;
UserRulesNormalizer.Normalize(rules);
Assert.Empty(rules.Categories);
Assert.Equal(["Jellyfin"], rules.FallbackFieldActions["poster"]);
}
}
+34
View File
@@ -0,0 +1,34 @@
# Browser Tests
These JF12-only integration tests create three temporary NFO movie fixtures,
seed deterministic Multilang data for their real Jellyfin IDs, and exercise
the ordinary web UI with Chromium. They cover proxy translation/fallback and
ordering, genre translation, per-user enablement, pre-cache population,
category-rule precedence, and both settings pages. The runner restores the
complete JF12 `config`, `data`, and `cache` volumes afterward. It refuses any target except
`jellyfin12` on port `8097`.
Install the ignored local dependencies once:
```bash
cd tests/browser
npm install
npx playwright install chromium
```
Run from the repository root with JF12 test-account credentials:
```bash
python3 tools/run_jellyfin12_browser_tests.py \
--base-url http://server.sedomain:8097 \
--username "$JELLYFIN_TEST_USER" \
--password "$JELLYFIN_TEST_PASSWORD" \
--ssh-host server.sedomain \
--state-root /mnt/apps-pool/Docker/Volumes/jellyfin12 \
--confirm-destructive
```
`node_modules`, browser artifacts, credentials, test results, and traces are
ignored. A failed run still restores the server state in a `finally` block; a
restoration hash mismatch intentionally leaves its remote snapshot in `/tmp`
for investigation.
+344
View File
@@ -0,0 +1,344 @@
const fs = require('node:fs');
const { expect, test } = require('@playwright/test');
const fixturePath = process.env.MULTILANG_BROWSER_FIXTURE;
if (!fixturePath) {
throw new Error('MULTILANG_BROWSER_FIXTURE must point to the temporary JF12 fixture description.');
}
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const actionFields = ['title', 'overview', 'tagline', 'poster', 'logo', 'banner', 'thumb', 'backdrop'];
function authHeaders() {
return { Authorization: `MediaBrowser Token="${fixture.token}"` };
}
function jellyfinActions() {
return Object.fromEntries(actionFields.map(field => [field, ['Jellyfin']]));
}
async function apiGet(request, path) {
const response = await request.get(path, { headers: authHeaders() });
expect(response.ok()).toBeTruthy();
return response.json();
}
async function apiPost(request, path, data) {
const response = await request.post(path, {
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
data
});
expect(response.ok()).toBeTruthy();
return response.json();
}
async function restoreFixtureRules(request) {
const config = await apiGet(request, '/Multilang/AdminConfig');
Object.assign(config, {
Languages: ['en', 'fi'],
ItemsProxyCacheThresholdMs: 0,
ItemsProxyCacheTtlMinutes: 5,
ItemsProxyCacheMaxMiB: 8,
PrecacheMovieLibraries: true,
PrecacheTvShowLibraries: false
});
await apiPost(request, '/Multilang/AdminConfig', config);
const rules = await apiGet(request, '/Multilang/UserRules/self');
Object.assign(rules, {
Enabled: true,
SortLocale: 'fi-FI',
Categories: [],
FallbackFieldActions: {
...jellyfinActions(),
title: ['Language:fi', 'Jellyfin'],
overview: ['Language:fi', 'Jellyfin'],
tagline: ['Language:fi', 'Jellyfin']
}
});
await apiPost(request, '/Multilang/UserRules/self', rules);
await apiPost(request, '/Multilang/ClearCache', {});
}
async function signIn(page) {
await page.goto('/web/index.html');
await page.getByRole('textbox', { name: 'User' }).fill(fixture.username);
await page.getByRole('textbox', { name: 'Password' }).fill(fixture.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/login/i);
}
async function openFixtureLibrary(page, expectedTitle) {
await page.goto(`/web/#/movies?topParentId=${fixture.libraryId}&collectionType=movies`);
await expect(page.getByText(expectedTitle, { exact: true })).toBeVisible();
}
async function getFixtureItems(request) {
const upstream = `/Users/${fixture.userId}/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&SortOrder=Ascending&Fields=Overview,Genres,GenreItems`;
const response = await request.get('/Multilang/ItemsProxy', {
headers: { Authorization: `MediaBrowser Token="${fixture.token}"` },
params: { url: upstream, mlLocale: 'fi-FI' }
});
expect(response.ok()).toBeTruthy();
return response.json();
}
test.beforeEach(async ({ request }) => {
await restoreFixtureRules(request);
});
test('pre-caches the fixture movie library after user activity', async ({ request }) => {
const trigger = await request.get('/Multilang/ItemsProxy', {
headers: authHeaders(),
params: {
url: `/Users/${fixture.userId}/Items/Latest?Limit=1`,
mlLocale: 'fi-FI'
}
});
expect(trigger.ok()).toBeTruthy();
await expect.poll(async () => {
const entries = await apiGet(request, '/Multilang/CacheEntries');
return entries.some(entry => entry.Url.includes(`ParentId=${fixture.libraryId}`));
}, { timeout: 60000 }).toBeTruthy();
await getFixtureItems(request);
const recent = await apiGet(request, '/Multilang/ItemsProxyRequests');
expect(recent[0].CacheHit).toBeTruthy();
});
test('shows configured translations and uses the injected ItemsProxy route', async ({ page, request }) => {
const rules = await apiGet(request, '/Multilang/UserRules/self');
rules.FallbackFieldActions.poster = ['Language:fi', 'Jellyfin'];
await apiPost(request, '/Multilang/UserRules/self', rules);
const proxiedRequests = [];
page.on('request', request => {
if (new URL(request.url()).pathname === '/Multilang/ItemsProxy') {
proxiedRequests.push(request.url());
}
});
await signIn(page);
const artworkResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === `/Multilang/Assets/${fixture.orderedItemIds[0]}/fixture.png`);
await openFixtureLibrary(page, fixture.translatedTitles[0]);
expect((await artworkResponse).status()).toBe(200);
for (const title of fixture.translatedTitles) {
await expect(page.getByText(title, { exact: true })).toBeVisible();
}
const payload = await getFixtureItems(request);
expect(payload.Items.map(item => item.Id)).toEqual(fixture.translatedOrderedItemIds);
expect(payload.Items.map(item => item.Name)).toEqual(fixture.translatedSortedTitles);
expect(payload.Items.find(item => item.Name === 'The Clock').Overview).toBe('English overview for The Clock.');
expect(payload.Items.find(item => item.Name === 'Ankkuri').Genres).toEqual(['Draama']);
expect(payload.Items.find(item => item.Name === 'Majakka').Genres).toEqual(['Komedia']);
await expect.poll(() => proxiedRequests.length).toBeGreaterThan(0);
});
test('returns to Jellyfin titles when Multilang is disabled for the user', async ({ page, request }) => {
const rules = await apiGet(request, '/Multilang/UserRules/self');
rules.Enabled = false;
await apiPost(request, '/Multilang/UserRules/self', rules);
await signIn(page);
await openFixtureLibrary(page, fixture.jellyfinTitles[0]);
for (const title of fixture.jellyfinTitles) {
await expect(page.getByText(title, { exact: true })).toBeVisible();
}
const payload = await getFixtureItems(request);
expect(payload.Items.map(item => item.Id)).toEqual(fixture.orderedItemIds);
expect(payload.Items.map(item => item.Name)).toEqual(fixture.jellyfinTitles);
expect(payload.Items.find(item => item.Name === 'The Anchor').Genres).toEqual(['Drama']);
expect(payload.Items.find(item => item.Name === 'The Beacon').Genres).toEqual(['Comedy']);
});
test('applies a structured category rule before fallback translation actions', async ({ request }) => {
const rules = await apiGet(request, '/Multilang/UserRules/self');
const categoryActions = jellyfinActions();
categoryActions.title = ['Jellyfin'];
rules.Categories = [{
Id: 'browser-fixture-swedish',
Label: 'Swedish originals',
Requirements: [{
Fields: ['original_language'],
Relation: 'is',
Values: ['sv'],
UseOr: false,
UseFieldOr: false
}],
MatchAllConditions: true,
Scopes: ['M'],
FieldActionLists: categoryActions,
}];
await apiPost(request, '/Multilang/UserRules/self', rules);
const payload = await getFixtureItems(request);
expect(payload.Items.map(item => item.Name)).toEqual(['Ankkuri', 'The Beacon', 'The Clock']);
});
test('saves admin configuration through the dashboard page', async ({ page, request }) => {
await signIn(page);
await page.goto('/web/#/configurationpage?name=Multilang');
await expect(page.locator('#ml-admin')).toBeVisible();
await page.locator('#ml-languages').fill('en, fi, sv');
await page.locator('#ml-precache-movies').check();
await page.locator('#ml-precache-tvshows').uncheck();
await page.locator('#ml-save').click();
await expect(page.locator('#ml-status')).toHaveText('Saved.');
const config = await apiGet(request, '/Multilang/AdminConfig');
expect(config.Languages).toEqual(['en', 'fi', 'sv']);
expect(config.PrecacheMovieLibraries).toBeTruthy();
expect(config.PrecacheTvShowLibraries).toBeFalsy();
});
test('renders provider controls without native replaceChildren support', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(Element.prototype, 'replaceChildren', { configurable: true, value: undefined });
});
await signIn(page);
await page.goto('/web/#/configurationpage?name=Multilang');
await expect(page.locator('#ml-provider-inputs input[data-provider]')).toHaveCount(2);
});
test('opens a resolved debug view from both settings pages', async ({ page }) => {
await signIn(page);
await page.goto('/web/#/configurationpage?name=Multilang');
await expect(page.locator('#ml-admin')).toBeVisible();
await page.locator('#ml-debug-item-id').fill(fixture.orderedItemIds[0]);
await page.locator('#ml-debug-user-id').fill(fixture.userId);
const [adminDebug] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#ml-debug-open').click()
]);
await expect(adminDebug).toHaveURL(new RegExp(`/Multilang/DebugUi/${fixture.orderedItemIds[0]}.*userId=${fixture.userId}`));
await expect(adminDebug.locator('#ml-debug-status')).toHaveText('Loaded.');
await adminDebug.close();
await page.goto(`/web/#/mypreferencesmenu?userId=${fixture.userId}`);
await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible();
await page.getByRole('button', { name: 'User Menu' }).click();
await expect(page.locator('.MuiMenu-list #mlUserRulesMenuItem')).toHaveCount(0);
await page.keyboard.press('Escape');
await page.locator('#mlUserRulesMenuItem').click();
await expect(page.locator('#ml-user')).toBeVisible();
await page.locator('#ml-debug-item-id').fill(fixture.orderedItemIds[0]);
const [userDebug] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#ml-debug-open').click()
]);
await expect(userDebug).toHaveURL(new RegExp(`/Multilang/DebugUi/${fixture.orderedItemIds[0]}`));
await expect(userDebug).not.toHaveURL(/userId=/);
await expect(userDebug.locator('#ml-debug-status')).toHaveText('Loaded.');
await userDebug.close();
});
test('saves a structured classification rule through the user settings page', async ({ page, request }) => {
await signIn(page);
await page.goto(`/web/#/mypreferencesmenu?userId=${fixture.userId}`);
await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible();
await page.locator('#mlUserRulesMenuItem').click();
await expect(page.locator('#ml-user')).toBeVisible();
await expect(page.locator('#ml-sort-locale option[value="en"]')).toHaveCount(1);
await page.locator('#ml-sort-locale').selectOption('en');
await page.getByRole('button', { name: 'Add classification rule' }).click();
const rule = page.locator('.ml-classification-rule').last();
await rule.locator('[data-category-label]').fill('Browser fixture rule');
await rule.locator('.ml-field-dropdown button').click();
await rule.locator('.ml-field-dropdown input[value="original_language"]').check();
await rule.locator('select.ml-relation-select').selectOption('is');
await rule.locator('.ml-value-dropdown').nth(1).locator('button').click();
await rule.locator('.ml-value-dropdown').nth(1).locator('input[value="sv"]').check();
await page.locator('#ml-save-user').click();
await expect(page.locator('#ml-user-status')).toHaveText('Saved.');
const rules = await apiGet(request, '/Multilang/UserRules/self');
expect(rules.SortLocale).toBe('en');
expect(rules.Categories).toHaveLength(1);
expect(rules.Categories[0].Label).toBe('Browser fixture rule');
expect(rules.Categories[0].Requirements).toEqual([expect.objectContaining({
Fields: ['original_language'],
Relation: 'is',
Values: ['sv']
})]);
});
test('rejects unsafe proxy targets and enforces restricted-user item access', async ({ request }) => {
for (const url of ['//outside.invalid/Items', '/System/Info', '/Multilang/ItemsProxy']) {
const response = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url } });
expect(response.status()).toBe(400);
}
const name = 'multilang-access-test-' + Date.now();
const user = await apiPost(request, '/Users/New', { Name: name });
const policy = { ...user.Policy, IsAdministrator: false, EnableAllFolders: true };
let response = await request.post(`/Users/${user.Id}/Policy`, { headers: authHeaders(), data: policy });
expect(response.ok()).toBeTruthy();
response = await request.post('/Users/AuthenticateByName', {
headers: { Authorization: 'MediaBrowser Client="Multilang tests", Device="test", DeviceId="multilang-access-test", Version="1"' },
data: { Username: name, Pw: '' }
});
expect(response.ok()).toBeTruthy();
const login = await response.json();
const headers = { Authorization: `MediaBrowser Token="${login.AccessToken}"` };
const asset = `/Multilang/Assets/${fixture.orderedItemIds[0]}/fixture.png`;
expect((await request.get(asset)).status()).toBe(401);
expect((await request.get(asset, { headers })).status()).toBe(200);
response = await request.post('/Multilang/UserRules/self', { headers, data: { Enabled: true, FallbackFieldActions: jellyfinActions() } });
expect(response.ok()).toBeTruthy();
const params = { url: `/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true&SortBy=SortName` };
response = await request.get('/Multilang/ItemsProxy', { headers, params });
expect(response.ok()).toBeTruthy();
expect((await response.json()).Items).toHaveLength(3);
let entries = await apiGet(request, '/Multilang/CacheEntries');
expect(entries.some(entry => entry.UserId === user.Id)).toBeTruthy();
policy.EnableAllFolders = false;
policy.EnabledFolders = [];
response = await request.post(`/Users/${user.Id}/Policy`, { headers: authHeaders(), data: policy });
expect(response.ok()).toBeTruthy();
response = await request.get('/Multilang/ItemsProxy', { headers, params });
const native = await request.get(`/Users/${user.Id}/Items?ParentId=${fixture.libraryId}`, { headers });
expect(native.status()).toBe(401);
expect(response.status()).toBe(native.status());
const recent = await apiGet(request, '/Multilang/ItemsProxyRequests');
expect(recent[0].CacheHit).toBeFalsy();
expect((await request.get(asset, { headers })).status()).toBe(404);
entries = await apiGet(request, '/Multilang/CacheEntries');
expect(entries.some(entry => entry.UserId === user.Id && entry.ItemCount > 0)).toBeFalsy();
for (const path of [`Debug/${fixture.orderedItemIds[0]}`, `Debug/${fixture.orderedItemIds[0]}/self`]) {
expect((await request.get(`/Multilang/${path}`, { headers })).status()).toBe(404);
}
expect((await request.post(`/Multilang/RefreshItem/${fixture.orderedItemIds[0]}?includeChildren=true`, { headers })).status()).toBe(404);
response = await request.get('/Multilang/ItemsProxy', { headers, params });
expect(response.status()).toBe(401);
});
test('reuses a library fetch across different pages and sorting controls', async ({ request }) => {
const base = `/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true`;
let response = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url: base + '&SortBy=SortName&Limit=1', mlLocale: 'fi-FI' } });
expect((await response.json()).Items.map(item => item.Name)).toEqual(['Ankkuri']);
response = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url: base + '&SortBy=SortName&StartIndex=1&Limit=1', mlLocale: 'fi-FI' } });
expect((await response.json()).Items.map(item => item.Name)).toEqual(['The Clock']);
const recent = await apiGet(request, '/Multilang/ItemsProxyRequests');
expect(recent[0].CacheHit).toBeTruthy();
});
test('reopens user settings without duplicate loads or a native replaceChildren dependency', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(Element.prototype, 'replaceChildren', { configurable: true, value: undefined });
});
await signIn(page);
await page.goto(`/web/#/mypreferencesmenu?userId=${fixture.userId}`);
let loads = 0;
page.on('request', request => { if (new URL(request.url()).pathname === '/Multilang/UserRules/self') loads++; });
for (let i = 0; i < 3; i++) {
await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible();
await page.locator('#mlUserRulesMenuItem').click();
await expect(page.locator('#ml-sort-locale option[value="en"]')).toHaveCount(1);
expect(loads).toBe(i + 1);
await page.evaluate(userId => { location.hash = '#/mypreferencesmenu?userId=' + userId; }, fixture.userId);
await expect(page.locator('#ml-user-shell')).toHaveCount(0);
}
});
+72
View File
@@ -0,0 +1,72 @@
{
"name": "jellyfin-multilang-browser-tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jellyfin-multilang-browser-tests",
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "jellyfin-multilang-browser-tests",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "1.61.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: '.',
testMatch: 'multilang.spec.js',
fullyParallel: false,
retries: 0,
reporter: 'list',
use: {
baseURL: process.env.JELLYFIN_BASE_URL,
screenshot: 'only-on-failure',
trace: 'retain-on-failure'
}
});
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Run deterministic Multilang browser checks against JF12 and restore its full state.
The runner creates a temporary local-NFO movie library, seeds only its real item
IDs into Multilang's SQLite database, runs Playwright, then restores and hashes
the JF12 config, data, cache, and injected web index. It refuses any target
other than the disposable jellyfin12 instance.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import subprocess
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import urlencode
from run_jellyfin12_live_tests import JellyfinApi, RemoteState, wait_for_login
FIXTURE_LIBRARY = "Multilang Browser Fixtures"
FIXTURE_PATH = "/config/multilang-browser-fixtures/media"
FIXTURES = (
("The Anchor", "Ankkuri", "Ankkurin suomenkielinen kuvaus", "Draama"),
("The Beacon", "Majakka", "Majakan suomenkielinen kuvaus", "Komedia"),
("The Clock", None, None, "Draama"),
)
def request_empty(api: JellyfinApi, method: str, path: str, payload: dict | None = None) -> None:
body = None if payload is None else json.dumps(payload).encode()
response = api.request(method, path, body, {"Content-Type": "application/json"} if body else None)
if not 200 <= response.status < 300:
raise RuntimeError(f"{method} {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
def write_fixture_files(remote: RemoteState) -> None:
host_fixture_path = f"{remote.args.state_root.rstrip('/')}/config/multilang-browser-fixtures/media"
files: dict[str, str] = {}
for index, (title, _, _, genre) in enumerate(FIXTURES, start=1):
directory = title.replace(" ", "_")
nfo = f"""<?xml version=\"1.0\" encoding=\"utf-8\"?>
<movie>
<title>{title}</title>
<sorttitle>{title}</sorttitle>
<originaltitle>{title}</originaltitle>
<plot>English overview for {title}.</plot>
<tagline>English tagline for {title}.</tagline>
<year>2024</year>
<premiered>2024-01-{index:02d}</premiered>
<genre>{'Drama' if genre == 'Draama' else 'Comedy'}</genre>
<language>en</language>
<uniqueid type=\"tmdb\" default=\"true\">99000{index}</uniqueid>
</movie>
"""
files[f"{directory}/movie.nfo"] = nfo
files[f"{directory}/{title}.mkv"] = ""
commands = [f"mkdir -p {shlex.quote(host_fixture_path)}"]
for relative, contents in files.items():
target = f"{host_fixture_path}/{relative}"
encoded = base64.b64encode(contents.encode()).decode()
commands.append(f"mkdir -p {shlex.quote(str(Path(target).parent))}")
commands.append(f"printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(target)}")
remote.run("bash -lc " + shlex.quote("; ".join(commands)))
def wait_for_fixture_items(api: JellyfinApi, user_id: str) -> tuple[str, dict[str, str]]:
for _ in range(90):
views = api.json("GET", f"/Users/{user_id}/Views").get("Items", [])
library = next((view for view in views if view.get("Name") == FIXTURE_LIBRARY), None)
if library:
items = api.json(
"GET",
f"/Users/{user_id}/Items?{urlencode({'ParentId': library['Id'], 'Recursive': 'true', 'IncludeItemTypes': 'Movie'})}",
).get("Items", [])
ids = {item.get("Name"): item.get("Id") for item in items}
if all(title in ids for title, *_ in FIXTURES):
return library["Id"], {title: ids[title] for title, *_ in FIXTURES}
time.sleep(1)
raise RuntimeError("JF12 did not index every deterministic browser fixture within 90 seconds.")
def create_fixture_library(api: JellyfinApi, user_id: str, remote: RemoteState) -> tuple[str, dict[str, str]]:
write_fixture_files(remote)
request_empty(
api,
"POST",
"/Library/VirtualFolders?" + urlencode({
"name": FIXTURE_LIBRARY,
"collectionType": "movies",
"paths": FIXTURE_PATH,
"refreshLibrary": "true",
}),
{"LibraryOptions": {"EnableRealtimeMonitor": False, "EnableInternetProviders": False}},
)
return wait_for_fixture_items(api, user_id)
def seed_translations(remote: RemoteState, item_ids: dict[str, str]) -> None:
rows = []
for index, (title, translated, overview, genre) in enumerate(FIXTURES, start=1):
rows.append({
"item_id": item_ids[title].replace("-", "").lower(),
"tmdb_id": f"99000{index}",
"original_title": title,
"original_language": "sv" if title == "The Beacon" else "en",
"translations": {"title": translated, "overview": overview, "tagline": translated and f"{translated} - suomenkielinen iskulause"},
"genre_id": 18 if genre == "Draama" else 35,
"genre": genre,
})
encoded = base64.b64encode(json.dumps(rows).encode()).decode()
script = r'''
import base64, json, os, sqlite3, sys, time
rows = json.loads(base64.b64decode(sys.argv[1]))
database = sys.argv[2]
connection = sqlite3.connect(database)
now = int(time.time())
for row in rows:
connection.execute("""
INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all,
origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language,
genre_tmdb_ids_json, missing_checked_at, full_checked_at)
VALUES (?, ?, 'movie', ?, ?, ?, '[\"US\"]', '[\"US\"]', '[\"en\"]', 'en', ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET tmdb_id=excluded.tmdb_id, kind=excluded.kind,
original_title=excluded.original_title, original_language=excluded.original_language,
original_language_all=excluded.original_language_all, genre_tmdb_ids_json=excluded.genre_tmdb_ids_json,
missing_checked_at=excluded.missing_checked_at, full_checked_at=excluded.full_checked_at
""", (row["item_id"], row["tmdb_id"], row["original_title"], row["original_language"], row["original_language"], json.dumps([row["genre_id"]]), now, now))
for field, value in row["translations"].items():
if value:
connection.execute("""
INSERT INTO translations(item_id, lang, field, text) VALUES (?, 'fi', ?, ?)
ON CONFLICT(item_id, lang, field) DO UPDATE SET text=excluded.text
""", (row["item_id"], field, value))
connection.execute("""
INSERT INTO genres(tmdb_id, media, lang, name, name_norm) VALUES (?, 'movie', 'fi', ?, ?)
ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET name=excluded.name, name_norm=excluded.name_norm
""", (row["genre_id"], row["genre"], row["genre"].lower()))
relative = row["item_id"] + "/fixture.png"
destination = os.path.join(os.path.dirname(database), "assets", relative)
os.makedirs(os.path.dirname(destination), exist_ok=True)
with open(destination, "wb") as image:
image.write(base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZ1kAAAAASUVORK5CYII="))
path = "/Multilang/Assets/" + relative
connection.execute("INSERT OR REPLACE INTO assets(item_id,lang,kind,path,path_low,updated_at) VALUES (?, 'fi', 'poster', ?, ?, ?)",
(row["item_id"], path, path.lower(), now))
connection.commit()
'''
remote.stop()
try:
database = f"{remote.args.state_root.rstrip('/')}/data/multilang/translations.sqlite"
remote.run("python3 -c " + shlex.quote(script) + " " + shlex.quote(encoded) + " " + shlex.quote(database))
finally:
remote.start()
def configure_multilang(api: JellyfinApi) -> None:
config = api.json("GET", "/Multilang/AdminConfig")
config.update({
"Languages": ["en", "fi"],
"ItemsProxyCacheThresholdMs": 0,
"ItemsProxyCacheTtlMinutes": 5,
"ItemsProxyCacheMaxMiB": 8,
})
api.json("POST", "/Multilang/AdminConfig", config)
rules = api.json("GET", "/Multilang/UserRules/self")
rules.update({
"Enabled": True,
"SortLocale": "fi-FI",
"Categories": [],
"FallbackFieldActions": {
"title": ["Language:fi", "Jellyfin"],
"overview": ["Language:fi", "Jellyfin"],
"tagline": ["Language:fi", "Jellyfin"],
"poster": ["Jellyfin"],
"logo": ["Jellyfin"],
"banner": ["Jellyfin"],
"thumb": ["Jellyfin"],
"backdrop": ["Jellyfin"],
},
})
api.json("POST", "/Multilang/UserRules/self", rules)
api.json("POST", "/Multilang/ClearCache")
def run_playwright(args: argparse.Namespace, fixture_file: Path) -> None:
browser_dir = Path(__file__).resolve().parents[1] / "tests" / "browser"
if not (browser_dir / "node_modules" / "@playwright" / "test").exists():
raise RuntimeError("Playwright is not installed. Run: cd tests/browser && npm install && npx playwright install chromium")
environment = os.environ | {
"JELLYFIN_BASE_URL": args.base_url.rstrip("/"),
"MULTILANG_BROWSER_FIXTURE": str(fixture_file),
}
result = subprocess.run(["npm", "test"], cwd=browser_dir, env=environment)
if result.returncode:
raise RuntimeError("Playwright browser checks failed.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", required=True)
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--ssh-host", required=True)
parser.add_argument("--ssh-user", default="root")
parser.add_argument("--local-docker", action="store_true")
parser.add_argument("--container", default="jellyfin12")
parser.add_argument("--state-root", required=True)
parser.add_argument("--confirm-destructive", action="store_true")
args = parser.parse_args()
if not args.confirm_destructive:
parser.error("--confirm-destructive is required")
if args.container != "jellyfin12" or not args.state_root.rstrip("/").endswith("/jellyfin12"):
parser.error("This runner only permits the disposable jellyfin12 instance.")
if ":8097" not in args.base_url:
parser.error("This runner only permits the JF12 URL on port 8097.")
args.full_server_state = True
return args
def main() -> int:
args = parse_args()
remote = RemoteState(args)
fixture_file = Path(__file__).resolve().parents[1] / "tests" / "browser" / ".auth" / f"fixture-{uuid.uuid4().hex}.json"
success = False
try:
remote.verify_target()
remote.snapshot()
api, user_id = wait_for_login(args.base_url, args.username, args.password)
library_id, item_ids = create_fixture_library(api, user_id, remote)
seed_translations(remote, item_ids)
api, user_id = wait_for_login(args.base_url, args.username, args.password)
configure_multilang(api)
fixture_file.parent.mkdir(parents=True, exist_ok=True)
fixture_file.write_text(json.dumps({
"username": args.username,
"password": args.password,
"token": api.token,
"userId": user_id,
"libraryId": library_id,
"jellyfinTitles": [title for title, *_ in FIXTURES],
"translatedTitles": [translated or title for title, translated, *_ in FIXTURES],
"orderedItemIds": [item_ids[title] for title, *_ in FIXTURES],
"translatedSortedTitles": ["Ankkuri", "The Clock", "Majakka"],
"translatedOrderedItemIds": [item_ids["The Anchor"], item_ids["The Clock"], item_ids["The Beacon"]],
}))
run_playwright(args, fixture_file)
success = True
finally:
fixture_file.unlink(missing_ok=True)
try:
if remote.expected_web_hash:
remote.restore_and_verify()
print("Restoration hash check passed for JF12 config, data, cache, and injected web index.")
remote.discard_snapshot()
else:
remote.discard_snapshot()
except Exception as error:
print(f"RESTORATION FAILED: {error}", file=sys.stderr)
return 2
return 0 if success else 1
if __name__ == "__main__":
raise SystemExit(main())
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env python3
"""Destructive Multilang integration checks with verified JF12 state restoration.
The script intentionally accepts all deployment details as arguments. It refuses
to run unless both the container and persistent state path are the JF12 test
instance. It snapshots only Multilang-owned state and the injected web index,
then restores and hashes those paths in a finally block. Run it from a separate
machine through SSH, or use --local-docker when it runs on the Docker host.
"""
from __future__ import annotations
import argparse
import json
import shlex
import subprocess
import sys
import time
import uuid
import zipfile
from dataclasses import dataclass
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
PLUGIN_CONFIG = "config/plugins/configurations/Jellyfin.Plugin.Multilang.xml"
PLUGIN_DATA = "data/multilang"
WEB_INDEX = "/jellyfin/jellyfin-web/index.html"
@dataclass(frozen=True)
class Response:
status: int
body: bytes
content_type: str
class JellyfinApi:
def __init__(self, base_url: str, token: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
def request(self, method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None) -> Response:
request_headers = dict(headers or {})
if self.token:
request_headers.setdefault("Authorization", f'MediaBrowser Token="{self.token}"')
request_headers.setdefault("X-Emby-Token", self.token)
request_headers.setdefault("X-MediaBrowser-Token", self.token)
request = Request(self.base_url + path, body, request_headers, method=method)
try:
with urlopen(request, timeout=60) as response:
return Response(response.status, response.read(), response.headers.get_content_type())
except HTTPError as error:
return Response(error.code, error.read(), error.headers.get_content_type())
def json(self, method: str, path: str, payload: Any | None = None) -> Any:
body = None if payload is None else json.dumps(payload).encode()
headers = {} if body is None else {"Content-Type": "application/json"}
response = self.request(method, path, body, headers)
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"{method} {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
return json.loads(response.body)
def multipart(self, path: str, fields: dict[str, str], filename: str, contents: bytes) -> Any:
boundary = "----multilang-live-test-" + uuid.uuid4().hex
chunks: list[bytes] = []
for key, value in fields.items():
chunks.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
])
chunks.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
b"Content-Type: application/zip\r\n\r\n",
contents,
b"\r\n",
f"--{boundary}--\r\n".encode(),
])
response = self.request("POST", path, b"".join(chunks), {"Content-Type": f"multipart/form-data; boundary={boundary}"})
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"POST {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
return json.loads(response.body)
class RemoteState:
def __init__(self, args: argparse.Namespace) -> None:
self.args = args
self.snapshot_dir = f"/tmp/multilang-live-test-{uuid.uuid4().hex}"
self.expected_hashes = ""
self.expected_web_hash = ""
def run(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
if self.args.local_docker:
result = subprocess.run(["bash", "-lc", command], text=True, capture_output=True)
else:
target = f"{self.args.ssh_user}@{self.args.ssh_host}"
result = subprocess.run(["ssh", target, command], text=True, capture_output=True)
if check and result.returncode:
raise RuntimeError(f"Remote command failed: {command}\n{result.stderr.strip()}")
return result
def docker(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return self.run("docker " + command, check)
def stop(self) -> None:
self.docker("stop " + shlex.quote(self.args.container))
def start(self) -> None:
self.docker("start " + shlex.quote(self.args.container))
def verify_target(self) -> None:
mounts = self.docker("inspect -f '{{ range .Mounts }}{{ println .Source }}{{ end }}' " + shlex.quote(self.args.container)).stdout
root = self.args.state_root.rstrip("/") + "/"
mounted_paths = mounts.splitlines()
if not any(path.startswith(root) for path in mounted_paths):
raise RuntimeError("The requested JF12 state root does not contain any mounts for the requested container.")
for required in ("config", "data"):
if root + required not in mounted_paths:
raise RuntimeError(f"The requested JF12 state root does not mount {required} into the requested container.")
if self.args.full_server_state and root + "cache" not in mounted_paths:
raise RuntimeError("Full-state verification requires the JF12 cache mount.")
def state_paths(self) -> tuple[str, ...]:
return ("config", "data", "cache") if self.args.full_server_state else (PLUGIN_DATA, PLUGIN_CONFIG)
def hash_plugin_state(self) -> str:
root = shlex.quote(self.args.state_root)
paths = " ".join(shlex.quote(path) for path in self.state_paths())
command = (
f"cd {root} && "
f"find {paths} -type f -print0 2>/dev/null "
"| sort -z | xargs -0 -r sha256sum"
)
return self.run(command).stdout
def snapshot(self) -> None:
self.stop()
try:
root = shlex.quote(self.args.state_root)
snapshot = shlex.quote(self.snapshot_dir)
paths = " ".join(shlex.quote(path) for path in self.state_paths())
self.run(f"mkdir -p {snapshot}")
self.expected_hashes = self.hash_plugin_state()
self.run(
f"tar -C {root} --ignore-failed-read -czf {snapshot}/plugin-state.tar.gz {paths}"
)
self.docker(
"cp " + shlex.quote(self.args.container + ":" + WEB_INDEX) + " " + shlex.quote(self.snapshot_dir + "/index.html")
)
self.expected_web_hash = self.run("sha256sum " + shlex.quote(self.snapshot_dir + "/index.html")).stdout.split()[0]
finally:
self.start()
def restore_and_verify(self) -> None:
self.stop()
try:
root = shlex.quote(self.args.state_root)
snapshot = shlex.quote(self.snapshot_dir)
removals = " ".join(f"{root}/{shlex.quote(path)}" for path in self.state_paths())
self.run(
f"rm -rf {removals} && "
f"tar -C {root} -xzf {snapshot}/plugin-state.tar.gz"
)
self.docker(
"cp " + shlex.quote(self.snapshot_dir + "/index.html") + " " + shlex.quote(self.args.container + ":" + WEB_INDEX)
)
actual_hashes = self.hash_plugin_state()
restored_index = self.snapshot_dir + "/restored-index.html"
self.docker(
"cp " + shlex.quote(self.args.container + ":" + WEB_INDEX) + " " + shlex.quote(restored_index)
)
actual_web_hash = self.run("sha256sum " + shlex.quote(restored_index)).stdout.split()[0]
if actual_hashes != self.expected_hashes or actual_web_hash != self.expected_web_hash:
raise RuntimeError(
"State restoration hash mismatch. The remote snapshot was retained at " + self.snapshot_dir
)
finally:
self.start()
def discard_snapshot(self) -> None:
self.run("rm -rf " + shlex.quote(self.snapshot_dir))
def login(base_url: str, username: str, password: str) -> tuple[JellyfinApi, str]:
api = JellyfinApi(base_url)
headers = {
"Content-Type": "application/json",
"Authorization": 'MediaBrowser Client="Multilang live tests", Device="Codex", DeviceId="multilang-live-tests", Version="1.0"',
}
response = api.request("POST", "/Users/AuthenticateByName", json.dumps({"Username": username, "Pw": password}).encode(), headers)
if response.status != 200:
raise RuntimeError(f"Authentication failed: HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
payload = json.loads(response.body)
return JellyfinApi(base_url, payload["AccessToken"]), payload["User"]["Id"]
def wait_for_login(base_url: str, username: str, password: str) -> tuple[JellyfinApi, str]:
for _ in range(60):
try:
return login(base_url, username, password)
except (OSError, URLError):
time.sleep(1)
continue
except RuntimeError as error:
if "HTTP 5" not in str(error):
raise
time.sleep(1)
raise RuntimeError("Jellyfin12 did not accept authentication within 60 seconds.")
def assert_equal(actual: Any, expected: Any, name: str) -> None:
if actual != expected:
raise AssertionError(f"{name}: expected {expected!r}, got {actual!r}")
def export_zip(api: JellyfinApi, query: dict[str, str]) -> bytes:
response = api.request("GET", "/Multilang/Export?" + urlencode(query))
if response.status != 200:
raise RuntimeError(f"Export failed with HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
with zipfile.ZipFile(BytesIO(response.body)) as archive:
if "manifest.json" not in archive.namelist():
raise AssertionError("Admin export did not contain manifest.json.")
return response.body
def choose_library(api: JellyfinApi, user_id: str) -> tuple[str, str]:
views = api.json("GET", f"/Users/{user_id}/Views")
for view in views.get("Items", []):
collection_type = str(view.get("CollectionType", "")).lower()
if collection_type in {"movies", "tvshows"}:
return view["Id"], "Movie" if collection_type == "movies" else "Series"
raise RuntimeError("No movie or TV-show library was available for the live proxy test.")
def proxy_path(user_id: str, library_id: str, item_type: str) -> str:
upstream = (
f"/Users/{user_id}/Items?ParentId={library_id}&IncludeItemTypes={item_type}"
"&Recursive=true&SortBy=SortName&SortOrder=Ascending&StartIndex=0&Limit=100"
)
return "/Multilang/ItemsProxy?" + urlencode({"url": upstream, "mlLocale": "en-US"})
def run_checks(api: JellyfinApi, user_id: str, include_refresh: bool) -> None:
print("[1/7] Checking configuration and user-rule persistence")
config = api.json("GET", "/Multilang/AdminConfig")
rules = api.json("GET", "/Multilang/UserRules/self")
baseline_export = export_zip(api, {
"pluginSettings": "true",
"userSettings": "true",
"translationsDatabase": "false",
"downloadedAssets": "false",
})
changed_config = dict(config)
changed_config["ItemsProxyCacheThresholdMs"] = 0 if config.get("ItemsProxyCacheThresholdMs") else 1
saved_config = api.json("POST", "/Multilang/AdminConfig", changed_config)
assert_equal(saved_config["ItemsProxyCacheThresholdMs"], changed_config["ItemsProxyCacheThresholdMs"], "admin configuration save")
changed_rules = dict(rules)
changed_rules["Enabled"] = True
changed_rules["SortLocale"] = "fi-FI"
saved_rules = api.json("POST", "/Multilang/UserRules/self", changed_rules)
assert_equal(saved_rules["Enabled"], True, "user rule enable")
assert_equal(saved_rules["SortLocale"], "fi-FI", "user sort locale save")
print("[2/7] Checking user and admin backup/import endpoints")
user_export = api.request("GET", "/Multilang/UserRules/self/export")
if user_export.status != 200:
raise RuntimeError(f"User export failed with HTTP {user_export.status}")
inspected = api.multipart("/Multilang/UserRules/self/import/inspect", {}, "user-rules.zip", user_export.body)
if not inspected.get("ContainsCurrentUser"):
raise AssertionError("User export inspection did not identify the exporting user.")
imported_user = api.multipart("/Multilang/UserRules/self/import", {}, "user-rules.zip", user_export.body)
if imported_user.get("UserSettingsImported") != 1:
raise AssertionError("User export did not import exactly one rule set.")
api.multipart(
"/Multilang/Import",
{"pluginSettings": "true", "userSettings": "true", "translationsDatabase": "false", "downloadedAssets": "false"},
"admin-export.zip",
baseline_export,
)
restored_config = api.json("GET", "/Multilang/AdminConfig")
assert_equal(restored_config["ItemsProxyCacheThresholdMs"], config["ItemsProxyCacheThresholdMs"], "admin export/import round trip")
print("[3/7] Checking transformed proxy responses, cache entries, and pre-cache triggering")
enabled_rules = dict(rules)
enabled_rules["Enabled"] = True
api.json("POST", "/Multilang/UserRules/self", enabled_rules)
cached_config = dict(config)
cached_config["ItemsProxyCacheThresholdMs"] = 0
cached_config["ItemsProxyCacheTtlMinutes"] = max(1, int(cached_config.get("ItemsProxyCacheTtlMinutes", 1)))
cached_config["ItemsProxyCacheMaxMiB"] = max(1, int(cached_config.get("ItemsProxyCacheMaxMiB", 1)))
api.json("POST", "/Multilang/AdminConfig", cached_config)
api.json("POST", "/Multilang/ClearCache")
library_id, item_type = choose_library(api, user_id)
path = proxy_path(user_id, library_id, item_type)
first = api.request("GET", path)
second = api.request("GET", path)
if first.status != 200 or second.status != 200:
raise RuntimeError(f"ItemsProxy returned {first.status} then {second.status}")
first_payload = json.loads(first.body)
if not isinstance(first_payload.get("Items"), list):
raise AssertionError("ItemsProxy response did not contain an Items list.")
if not first_payload["Items"]:
raise RuntimeError("The selected library had no items for the cleanup probe.")
probe_item_id = first_payload["Items"][0]["Id"]
requests = api.json("GET", "/Multilang/ItemsProxyRequests")
if not any(request.get("CacheHit") for request in requests):
raise AssertionError("Second proxy request did not produce a cache hit.")
entries = api.json("GET", "/Multilang/CacheEntries")
if not entries:
raise AssertionError("ItemsProxy did not store a cache entry at a zero-millisecond threshold.")
print("[4/7] Checking concurrent proxy requests remain valid")
api.json("POST", "/Multilang/ClearCache")
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda _: api.request("GET", path), range(2)))
if any(response.status != 200 for response in responses):
raise AssertionError("Concurrent proxy request did not return HTTP 200.")
if include_refresh:
print("[5/7] Checking one-item refresh and debug data")
refreshed = api.json("POST", f"/Multilang/RefreshItem/{probe_item_id}?includeChildren=false")
assert_equal(refreshed["ItemId"].lower(), probe_item_id.replace("-", "").lower(), "single-item refresh")
debug = api.json("GET", f"/Multilang/Debug/{probe_item_id}")
assert_equal(debug["ItemId"].lower(), probe_item_id.replace("-", "").lower(), "debug item identity")
else:
print("[5/7] Skipping the provider-backed refresh probe (pass --include-refresh to run it)")
print("[6/7] Checking cleanup endpoint and default-state recovery")
api.json("POST", "/Multilang/CleanupAll", {"Confirm": True})
cleanup_info = api.json("GET", "/Multilang/ExportInfo")
if cleanup_info["AssetsBytes"] != 0:
raise AssertionError("CleanupAll did not remove local assets.")
default_rules = api.json("GET", "/Multilang/UserRules/self")
if default_rules.get("Enabled"):
raise AssertionError("CleanupAll did not remove the user rule document.")
if api.json("GET", f"/Multilang/Debug/{probe_item_id}").get("Facts") is not None:
raise AssertionError("CleanupAll did not remove stored item facts.")
print("[7/7] Live checks passed; raw restoration will now run")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", required=True, help="JF12 base URL, expected to use port 8097")
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--ssh-host", required=True)
parser.add_argument("--ssh-user", default="root")
parser.add_argument("--local-docker", action="store_true", help="Run Docker and filesystem commands on this host instead of through SSH")
parser.add_argument("--container", default="jellyfin12")
parser.add_argument("--state-root", required=True, help="JF12 persistent volume root")
parser.add_argument("--confirm-destructive", action="store_true", help="Required: tests modify JF12 Multilang state before restoring it")
parser.add_argument("--include-refresh", action="store_true", help="Also run a provider-backed single-item refresh; this can take longer than the core suite")
parser.add_argument("--full-server-state", action="store_true", help="Snapshot and restore the JF12 config, data, and cache mounts instead of only Multilang-owned paths")
args = parser.parse_args()
parsed = urlparse(args.base_url)
if not args.confirm_destructive:
parser.error("--confirm-destructive is required")
if args.container != "jellyfin12":
parser.error("This runner only permits the jellyfin12 container")
if parsed.port != 8097:
parser.error("This runner only permits the JF12 test URL on port 8097")
if parsed.hostname in {"127.0.0.1", "::1", "localhost"}:
parser.error("Use a LAN host name or address reachable from inside the Jellyfin container, not loopback.")
if not args.state_root.rstrip("/").endswith("/jellyfin12"):
parser.error("This runner only permits a state root ending in /jellyfin12")
return args
def main() -> int:
args = parse_args()
remote = RemoteState(args)
success = False
try:
remote.verify_target()
remote.snapshot()
api, user_id = wait_for_login(args.base_url, args.username, args.password)
run_checks(api, user_id, args.include_refresh)
success = True
finally:
try:
if remote.expected_web_hash:
remote.restore_and_verify()
scope = "JF12 config, data, cache, and injected web index" if args.full_server_state else "Multilang state and injected web index"
print(f"Restoration hash check passed for {scope}.")
remote.discard_snapshot()
else:
remote.discard_snapshot()
except Exception as error:
print(f"RESTORATION FAILED: {error}", file=sys.stderr)
return 2
return 0 if success else 1
if __name__ == "__main__":
raise SystemExit(main())