Compare commits
10
Commits
main
...
jellyfin12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
096c6be8c7 | ||
|
|
8f7462fd3c | ||
|
|
d365f7b58b | ||
|
|
094d5ad9f0 | ||
|
|
25088ce91d | ||
|
|
fd1cf12b6b | ||
|
|
3c82485a0a | ||
|
|
8c79cfd3e6 | ||
|
|
a6acccfa66 | ||
|
|
e38ccf7a0c |
@@ -16,3 +16,10 @@ old-attempt/
|
|||||||
# Generated artifacts
|
# Generated artifacts
|
||||||
*.pyc
|
*.pyc
|
||||||
*.sqlite
|
*.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
|
||||||
|
|||||||
@@ -3,7 +3,20 @@
|
|||||||
Multilang is a Jellyfin plugin that lets different users see different metadata
|
Multilang is a Jellyfin plugin that lets different users see different metadata
|
||||||
languages without changing Jellyfin's stored 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
|
## What It Does
|
||||||
|
|
||||||
@@ -86,9 +99,12 @@ the UI.
|
|||||||
Some requests can be slow because Multilang may need to ask Jellyfin for a
|
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
|
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.
|
translate, filter, sort, and slice the list using the user's own rules.
|
||||||
Multilang caches slow transformed responses for a configurable time and also
|
Multilang caches Jellyfin's responses for a configurable time, sharing the fetched
|
||||||
uses a very short burst cache for repeated requests during navigation. Cache
|
list across pages and sort orders. Current translations and user rules are applied
|
||||||
entries are invalidated when affected items are refreshed.
|
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
|
## Original-Language Support
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"sdk": {
|
"sdk": {
|
||||||
"version": "9.0.116",
|
"version": "10.0.110",
|
||||||
"rollForward": "latestFeature"
|
"rollForward": "latestFeature"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Jellyfin.Data;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -17,6 +19,7 @@ using MediaBrowser.Controller.Entities;
|
|||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -26,15 +29,6 @@ namespace Jellyfin.Plugin.Multilang.Api;
|
|||||||
[Route("Multilang")]
|
[Route("Multilang")]
|
||||||
public sealed class MultilangController : ControllerBase
|
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 TranslationStore _store;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly IAuthorizationContext _authorizationContext;
|
private readonly IAuthorizationContext _authorizationContext;
|
||||||
@@ -42,11 +36,14 @@ public sealed class MultilangController : ControllerBase
|
|||||||
private readonly IUserManager _userManager;
|
private readonly IUserManager _userManager;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
private readonly ProviderCatalog _providerCatalog;
|
private readonly ProviderCatalog _providerCatalog;
|
||||||
private readonly SortArticleCatalog _articleCatalog;
|
private readonly SortArticleCatalog _articleCatalog;
|
||||||
private readonly RefreshService _refreshService;
|
private readonly RefreshService _refreshService;
|
||||||
private readonly FanartClient _fanartClient;
|
private readonly FanartClient _fanartClient;
|
||||||
private readonly ItemsProxyCache _itemsProxyCache;
|
private readonly ItemsProxyCache _itemsProxyCache;
|
||||||
|
private readonly ItemsProxyRequestCoalescer _itemsProxyRequestCoalescer;
|
||||||
|
private readonly ItemsProxyPrecacheService _itemsProxyPrecacheService;
|
||||||
private readonly ItemsProxyTransformer _itemsProxyTransformer;
|
private readonly ItemsProxyTransformer _itemsProxyTransformer;
|
||||||
private readonly AssetStorageService _assetStorage;
|
private readonly AssetStorageService _assetStorage;
|
||||||
private readonly MultilangBackupService _backupService;
|
private readonly MultilangBackupService _backupService;
|
||||||
@@ -59,11 +56,14 @@ public sealed class MultilangController : ControllerBase
|
|||||||
IUserManager userManager,
|
IUserManager userManager,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IApplicationPaths appPaths,
|
IApplicationPaths appPaths,
|
||||||
|
ILocalizationManager localizationManager,
|
||||||
ProviderCatalog providerCatalog,
|
ProviderCatalog providerCatalog,
|
||||||
SortArticleCatalog articleCatalog,
|
SortArticleCatalog articleCatalog,
|
||||||
RefreshService refreshService,
|
RefreshService refreshService,
|
||||||
FanartClient fanartClient,
|
FanartClient fanartClient,
|
||||||
ItemsProxyCache itemsProxyCache,
|
ItemsProxyCache itemsProxyCache,
|
||||||
|
ItemsProxyRequestCoalescer itemsProxyRequestCoalescer,
|
||||||
|
ItemsProxyPrecacheService itemsProxyPrecacheService,
|
||||||
ItemsProxyTransformer itemsProxyTransformer,
|
ItemsProxyTransformer itemsProxyTransformer,
|
||||||
AssetStorageService assetStorage,
|
AssetStorageService assetStorage,
|
||||||
MultilangBackupService backupService)
|
MultilangBackupService backupService)
|
||||||
@@ -75,11 +75,14 @@ public sealed class MultilangController : ControllerBase
|
|||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
|
_localizationManager = localizationManager;
|
||||||
_providerCatalog = providerCatalog;
|
_providerCatalog = providerCatalog;
|
||||||
_articleCatalog = articleCatalog;
|
_articleCatalog = articleCatalog;
|
||||||
_refreshService = refreshService;
|
_refreshService = refreshService;
|
||||||
_fanartClient = fanartClient;
|
_fanartClient = fanartClient;
|
||||||
_itemsProxyCache = itemsProxyCache;
|
_itemsProxyCache = itemsProxyCache;
|
||||||
|
_itemsProxyRequestCoalescer = itemsProxyRequestCoalescer;
|
||||||
|
_itemsProxyPrecacheService = itemsProxyPrecacheService;
|
||||||
_itemsProxyTransformer = itemsProxyTransformer;
|
_itemsProxyTransformer = itemsProxyTransformer;
|
||||||
_assetStorage = assetStorage;
|
_assetStorage = assetStorage;
|
||||||
_backupService = backupService;
|
_backupService = backupService;
|
||||||
@@ -98,10 +101,18 @@ public sealed class MultilangController : ControllerBase
|
|||||||
=> ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css");
|
=> ServeEmbedded("Jellyfin.Plugin.Multilang.Configuration.shared.css", "text/css");
|
||||||
|
|
||||||
[HttpGet("Assets/{**path}")]
|
[HttpGet("Assets/{**path}")]
|
||||||
public IActionResult Asset(string path)
|
public async Task<IActionResult> Asset(string path)
|
||||||
=> _assetStorage.TryResolveLocalAsset(path, out var fullPath, out var contentType)
|
{
|
||||||
? PhysicalFile(fullPath, contentType)
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
||||||
: NotFound();
|
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")]
|
[HttpGet("Providers")]
|
||||||
public IActionResult Providers()
|
public IActionResult Providers()
|
||||||
@@ -111,6 +122,13 @@ public sealed class MultilangController : ControllerBase
|
|||||||
public IActionResult ArticleBuiltins()
|
public IActionResult ArticleBuiltins()
|
||||||
=> Ok(_articleCatalog.GetBuiltIns().OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(k => k.Key, v => v.Value));
|
=> 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")]
|
[HttpGet("AdminConfig")]
|
||||||
public async Task<IActionResult> AdminConfig()
|
public async Task<IActionResult> AdminConfig()
|
||||||
{
|
{
|
||||||
@@ -143,6 +161,8 @@ public sealed class MultilangController : ControllerBase
|
|||||||
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
|
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
|
||||||
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
|
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
|
||||||
!string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.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);
|
!ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries);
|
||||||
|
|
||||||
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
|
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
|
||||||
@@ -195,10 +215,9 @@ public sealed class MultilangController : ControllerBase
|
|||||||
return Unauthorized(new { Error = "NotAuthenticated" });
|
return Unauthorized(new { Error = "NotAuthenticated" });
|
||||||
|
|
||||||
var allowed = (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var allowed = (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
request.Categories = NormalizeCategories(request.Categories, allowed);
|
UserRulesNormalizer.Normalize(request, allowed);
|
||||||
request.FallbackFieldActions = NormalizeActionLists(request.FallbackFieldActions, allowed);
|
|
||||||
_store.SaveUserRules(userId, request);
|
_store.SaveUserRules(userId, request);
|
||||||
_itemsProxyCache.ClearAll();
|
_itemsProxyCache.ClearUser(userId);
|
||||||
return Ok(request);
|
return Ok(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,12 +265,16 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (!TryParseItemId(itemId, out _, out var itemGuid))
|
if (!TryParseItemId(itemId, out _, out var itemGuid))
|
||||||
return BadRequest(new { Error = "InvalidItemId" });
|
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 });
|
return Ok(new { ItemId = itemGuid.ToString("N"), IncludeChildren = includeChildren });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("ItemsProxy")]
|
[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))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
return BadRequest(new { Error = "MissingUrl" });
|
return BadRequest(new { Error = "MissingUrl" });
|
||||||
@@ -264,8 +287,13 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
return Unauthorized(new { Error = "NotAuthenticated" });
|
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 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)
|
if (proxyRequest is null)
|
||||||
return BadRequest(new { Error = "InvalidUrl" });
|
return BadRequest(new { Error = "InvalidUrl" });
|
||||||
|
|
||||||
@@ -273,8 +301,19 @@ public sealed class MultilangController : ControllerBase
|
|||||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0;
|
var cacheAllowed = rules.Enabled && cfg.ItemsProxyCacheTtlMinutes > 0 && cfg.ItemsProxyCacheMaxMiB > 0;
|
||||||
var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes));
|
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))
|
if (cacheAllowed && _itemsProxyCache.TryGet(proxyRequest.CacheKey, cacheTtl, out var cached))
|
||||||
{
|
{
|
||||||
|
var transformed = _itemsProxyTransformer.TransformItemsResponse(cached.Body, rules, proxyRequest.Controls);
|
||||||
totalSw.Stop();
|
totalSw.Stop();
|
||||||
_itemsProxyCache.RecordRequest(
|
_itemsProxyCache.RecordRequest(
|
||||||
proxyRequest.NormalizedUrlForCache,
|
proxyRequest.NormalizedUrlForCache,
|
||||||
@@ -284,9 +323,9 @@ public sealed class MultilangController : ControllerBase
|
|||||||
itemCount: cached.ItemCount,
|
itemCount: cached.ItemCount,
|
||||||
totalMs: totalSw.ElapsedMilliseconds,
|
totalMs: totalSw.ElapsedMilliseconds,
|
||||||
upstreamMs: 0,
|
upstreamMs: 0,
|
||||||
transformMs: 0,
|
transformMs: totalSw.ElapsedMilliseconds,
|
||||||
sizeBytes: cached.SizeBytes);
|
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))
|
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 upstreamSw = Stopwatch.StartNew();
|
||||||
var http = _httpClientFactory.CreateClient();
|
var upstream = await _itemsProxyRequestCoalescer.GetOrFetchAsync(
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, proxyRequest.Upstream);
|
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-Emby-Token", token);
|
||||||
request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token);
|
request.Headers.TryAddWithoutValidation("X-MediaBrowser-Token", token);
|
||||||
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
using var response = await http.SendAsync(request, sharedCancellationToken).ConfigureAwait(false);
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).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();
|
upstreamSw.Stop();
|
||||||
|
var body = upstream.Response.Body;
|
||||||
string[] responseItemIds = [];
|
string[] responseItemIds = [];
|
||||||
var transformMs = 0L;
|
var transformMs = 0L;
|
||||||
if (response.IsSuccessStatusCode && rules.Enabled)
|
if (upstream.Response.IsSuccessStatusCode && rules.Enabled)
|
||||||
{
|
{
|
||||||
var transformSw = Stopwatch.StartNew();
|
var transformSw = Stopwatch.StartNew();
|
||||||
var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls);
|
var transformed = _itemsProxyTransformer.TransformItemsResponse(body, rules, proxyRequest.Controls);
|
||||||
@@ -327,11 +379,11 @@ public sealed class MultilangController : ControllerBase
|
|||||||
}
|
}
|
||||||
totalSw.Stop();
|
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 bodySizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
|
||||||
var cacheStored = false;
|
var cacheStored = false;
|
||||||
if (cacheAllowed &&
|
if (cacheAllowed &&
|
||||||
response.IsSuccessStatusCode &&
|
upstream.Response.IsSuccessStatusCode &&
|
||||||
responseItemIds.Length > 0 &&
|
responseItemIds.Length > 0 &&
|
||||||
totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs))
|
totalSw.ElapsedMilliseconds >= Math.Max(0, cfg.ItemsProxyCacheThresholdMs))
|
||||||
{
|
{
|
||||||
@@ -339,62 +391,57 @@ public sealed class MultilangController : ControllerBase
|
|||||||
proxyRequest.CacheKey,
|
proxyRequest.CacheKey,
|
||||||
userId,
|
userId,
|
||||||
proxyRequest.NormalizedUrlForCache,
|
proxyRequest.NormalizedUrlForCache,
|
||||||
responseItemIds,
|
responseItemIds.Length,
|
||||||
body,
|
upstream.Response.Body,
|
||||||
contentType,
|
contentType,
|
||||||
totalSw.ElapsedMilliseconds,
|
totalSw.ElapsedMilliseconds,
|
||||||
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L,
|
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L,
|
||||||
cacheTtl);
|
cacheTtl,
|
||||||
|
generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cacheAllowed &&
|
if (cacheAllowed &&
|
||||||
!cacheStored &&
|
!cacheStored &&
|
||||||
response.IsSuccessStatusCode &&
|
upstream.Response.IsSuccessStatusCode &&
|
||||||
responseItemIds.Length > 0)
|
responseItemIds.Length > 0)
|
||||||
{
|
{
|
||||||
_itemsProxyCache.StoreMicro(
|
_itemsProxyCache.StoreMicro(
|
||||||
proxyRequest.CacheKey,
|
proxyRequest.CacheKey,
|
||||||
userId,
|
userId,
|
||||||
proxyRequest.NormalizedUrlForCache,
|
proxyRequest.NormalizedUrlForCache,
|
||||||
responseItemIds,
|
responseItemIds.Length,
|
||||||
body,
|
upstream.Response.Body,
|
||||||
contentType,
|
contentType,
|
||||||
totalSw.ElapsedMilliseconds,
|
totalSw.ElapsedMilliseconds,
|
||||||
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L);
|
cfg.ItemsProxyCacheMaxMiB * 1024L * 1024L,
|
||||||
|
generation,
|
||||||
|
cacheTtl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_itemsProxyCache.RecordRequest(
|
_itemsProxyCache.RecordRequest(
|
||||||
proxyRequest.NormalizedUrlForCache,
|
proxyRequest.NormalizedUrlForCache,
|
||||||
cacheHit: false,
|
cacheHit: false,
|
||||||
cacheStored: cacheStored,
|
cacheStored: cacheStored,
|
||||||
statusCode: (int)response.StatusCode,
|
statusCode: upstream.Response.StatusCode,
|
||||||
itemCount: responseItemIds.Length,
|
itemCount: responseItemIds.Length,
|
||||||
totalMs: totalSw.ElapsedMilliseconds,
|
totalMs: totalSw.ElapsedMilliseconds,
|
||||||
upstreamMs: upstreamSw.ElapsedMilliseconds,
|
upstreamMs: upstreamSw.ElapsedMilliseconds,
|
||||||
transformMs: transformMs,
|
transformMs: transformMs,
|
||||||
sizeBytes: bodySizeBytes);
|
sizeBytes: bodySizeBytes,
|
||||||
|
inFlightCoalesced: upstream.JoinedExistingRequest);
|
||||||
|
|
||||||
return new ContentResult
|
return new ContentResult
|
||||||
{
|
{
|
||||||
StatusCode = (int)response.StatusCode,
|
StatusCode = upstream.Response.StatusCode,
|
||||||
Content = body,
|
Content = body,
|
||||||
ContentType = contentType
|
ContentType = contentType
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record DebugResolutionAttempt(
|
|
||||||
string Action,
|
|
||||||
string Source,
|
|
||||||
string LookupKey,
|
|
||||||
bool HasValue,
|
|
||||||
bool Chosen,
|
|
||||||
string? Value,
|
|
||||||
string Reason);
|
|
||||||
|
|
||||||
private sealed record DebugTextResolution(
|
private sealed record DebugTextResolution(
|
||||||
string Field,
|
string Field,
|
||||||
string[] Actions,
|
string[] Actions,
|
||||||
DebugResolutionAttempt[] Attempts,
|
ItemsProxyResolutionAttempt[] Attempts,
|
||||||
ItemsProxyResolvedText Result,
|
ItemsProxyResolvedText Result,
|
||||||
string SelectedAction,
|
string SelectedAction,
|
||||||
string Reason);
|
string Reason);
|
||||||
@@ -402,7 +449,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
private sealed record DebugAssetResolution(
|
private sealed record DebugAssetResolution(
|
||||||
string Kind,
|
string Kind,
|
||||||
string[] Actions,
|
string[] Actions,
|
||||||
DebugResolutionAttempt[] Attempts,
|
ItemsProxyResolutionAttempt[] Attempts,
|
||||||
ItemsProxyResolvedAsset Result,
|
ItemsProxyResolvedAsset Result,
|
||||||
string SelectedAction,
|
string SelectedAction,
|
||||||
string Reason);
|
string Reason);
|
||||||
@@ -473,6 +520,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (downloadedAssets && !translationsDatabase)
|
if (downloadedAssets && !translationsDatabase)
|
||||||
return BadRequest(new { Error = "AssetsRequireDatabase" });
|
return BadRequest(new { Error = "AssetsRequireDatabase" });
|
||||||
|
|
||||||
|
using var maintenance = await _store.EnterMaintenanceAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
var bytes = _backupService.Export(
|
var bytes = _backupService.Export(
|
||||||
new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets),
|
new BackupExportOptions(pluginSettings, userSettings, translationsDatabase, downloadedAssets),
|
||||||
Plugin.Instance?.Configuration ?? new PluginConfiguration());
|
Plugin.Instance?.Configuration ?? new PluginConfiguration());
|
||||||
@@ -502,6 +550,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
|
using var maintenance = await _store.EnterMaintenanceAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
var result = _backupService.ImportAdmin(
|
var result = _backupService.ImportAdmin(
|
||||||
stream,
|
stream,
|
||||||
options,
|
options,
|
||||||
@@ -525,6 +574,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (request.Confirm != true)
|
if (request.Confirm != true)
|
||||||
return BadRequest(new { Error = "ConfirmationRequired" });
|
return BadRequest(new { Error = "ConfirmationRequired" });
|
||||||
|
|
||||||
|
using var maintenance = await _store.EnterMaintenanceAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
Plugin.Instance?.UpdateConfiguration(new PluginConfiguration());
|
Plugin.Instance?.UpdateConfiguration(new PluginConfiguration());
|
||||||
_store.ResetAll();
|
_store.ResetAll();
|
||||||
_itemsProxyCache.ClearAll();
|
_itemsProxyCache.ClearAll();
|
||||||
@@ -583,7 +633,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
var result = _backupService.ImportUser(stream, userId, form.TryGetValue("sourceUserId", out var sourceUserId) ? sourceUserId.ToString() : null);
|
var result = _backupService.ImportUser(stream, userId, form.TryGetValue("sourceUserId", out var sourceUserId) ? sourceUserId.ToString() : null);
|
||||||
if (result.UserSettingsImported > 0)
|
if (result.UserSettingsImported > 0)
|
||||||
_itemsProxyCache.ClearAll();
|
_itemsProxyCache.ClearUser(userId);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
@@ -599,9 +649,12 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
return Unauthorized(new { Error = "NotAuthenticated" });
|
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" });
|
return BadRequest(new { Error = "InvalidItemId" });
|
||||||
|
|
||||||
|
if (!CanAccessItem(itemGuid, userId))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
ItemId = itemId32,
|
ItemId = itemId32,
|
||||||
@@ -631,7 +684,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (normalizedUserId is null)
|
if (normalizedUserId is null)
|
||||||
return BadRequest(new { Error = "InvalidUserId" });
|
return BadRequest(new { Error = "InvalidUserId" });
|
||||||
|
|
||||||
return DebugResolveForUser(itemId, normalizedUserId);
|
return DebugResolveForUser(itemId, normalizedUserId, administrator: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("DebugResolve/{itemId}")]
|
[HttpGet("DebugResolve/{itemId}")]
|
||||||
@@ -644,11 +697,14 @@ public sealed class MultilangController : ControllerBase
|
|||||||
return DebugResolveForUser(itemId, userId);
|
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" });
|
return BadRequest(new { Error = "InvalidItemId" });
|
||||||
|
|
||||||
|
if (!administrator && !CanAccessItem(itemGuid, userId))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
var facts = _store.GetFacts(itemId32);
|
var facts = _store.GetFacts(itemId32);
|
||||||
if (facts is null)
|
if (facts is null)
|
||||||
return NotFound(new { Error = "FactsMissing" });
|
return NotFound(new { Error = "FactsMissing" });
|
||||||
@@ -767,128 +823,27 @@ public sealed class MultilangController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static DebugTextResolution TraceResolveField(
|
private static DebugTextResolution TraceResolveField(
|
||||||
string field,
|
string field, IReadOnlyList<string> actions, FactsData facts,
|
||||||
IReadOnlyList<string> actions,
|
|
||||||
FactsData facts,
|
|
||||||
Dictionary<string, Dictionary<string, string>>? byLang)
|
Dictionary<string, Dictionary<string, string>>? byLang)
|
||||||
{
|
{
|
||||||
var attempts = new List<DebugResolutionAttempt>();
|
var attempts = new List<ItemsProxyResolutionAttempt>();
|
||||||
foreach (var action in actions)
|
var result = ItemsProxyTransformer.ResolveField(field, actions, facts, byLang, attempts.Add);
|
||||||
{
|
var chosen = attempts.FirstOrDefault(attempt => attempt.Chosen);
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
return new(field, actions.ToArray(), attempts.ToArray(), result,
|
||||||
{
|
chosen?.Action ?? (result.Change ? ClearAction : JellyfinAction),
|
||||||
var result = new ItemsProxyResolvedText(false, null);
|
chosen?.Reason ?? (result.Change ? "No action had data; field is cleared" : "No action had data; use Jellyfin"));
|
||||||
attempts.Add(new DebugResolutionAttempt(action, JellyfinAction, string.Empty, true, true, null, "Use Jellyfin value"));
|
|
||||||
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var hasOriginalTitle = !string.IsNullOrWhiteSpace(facts.OriginalTitle);
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "OriginalTitle", "facts.original_title", hasOriginalTitle, hasOriginalTitle, facts.OriginalTitle, hasOriginalTitle ? "Original title found" : "Original title missing"));
|
|
||||||
if (hasOriginalTitle)
|
|
||||||
{
|
|
||||||
var result = new ItemsProxyResolvedText(true, facts.OriginalTitle);
|
|
||||||
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original title found");
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var originalValue = ItemsProxyTransformer.GetTranslatedField(byLang, OriginalAction, field);
|
|
||||||
var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue);
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Translation", "Original/" + field, hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language translation found" : "Original-language translation missing"));
|
|
||||||
if (hasOriginalValue)
|
|
||||||
{
|
|
||||||
var result = new ItemsProxyResolvedText(true, originalValue);
|
|
||||||
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Original-language translation found");
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lang = action[LanguagePrefix.Length..];
|
|
||||||
var value = ItemsProxyTransformer.GetTranslatedField(byLang, lang, field);
|
|
||||||
var hasValue = !string.IsNullOrWhiteSpace(value);
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Translation", lang + "/" + field, hasValue, hasValue, value, hasValue ? "Translation found" : "Translation missing or empty"));
|
|
||||||
if (hasValue)
|
|
||||||
{
|
|
||||||
var result = new ItemsProxyResolvedText(true, value);
|
|
||||||
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), result, action, "Translation found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallback = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? new ItemsProxyResolvedText(false, null)
|
|
||||||
: new ItemsProxyResolvedText(true, string.Empty);
|
|
||||||
var reason = field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "No action had data; title falls back to Jellyfin"
|
|
||||||
: "No action had data; field is cleared";
|
|
||||||
return new DebugTextResolution(field, actions.ToArray(), attempts.ToArray(), fallback, field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) ? JellyfinAction : ClearAction, reason);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DebugAssetResolution TraceResolveAsset(
|
private static DebugAssetResolution TraceResolveAsset(
|
||||||
string kind,
|
string kind, IReadOnlyList<string> actions,
|
||||||
IReadOnlyList<string> actions,
|
|
||||||
Dictionary<string, Dictionary<string, string>>? byKind)
|
Dictionary<string, Dictionary<string, string>>? byKind)
|
||||||
{
|
{
|
||||||
var attempts = new List<DebugResolutionAttempt>();
|
var attempts = new List<ItemsProxyResolutionAttempt>();
|
||||||
foreach (var action in actions)
|
var result = ItemsProxyTransformer.ResolveAsset(kind, actions, byKind, attempts.Add);
|
||||||
{
|
var chosen = attempts.FirstOrDefault(attempt => attempt.Chosen);
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
return new(kind, actions.ToArray(), attempts.ToArray(), result,
|
||||||
{
|
chosen?.Action ?? (result.Change ? ClearAction : JellyfinAction),
|
||||||
var result = new ItemsProxyResolvedAsset(false, null);
|
chosen?.Reason ?? (result.Change ? "No action had data; image is cleared" : "No action had data; use Jellyfin"));
|
||||||
attempts.Add(new DebugResolutionAttempt(action, JellyfinAction, string.Empty, true, true, null, "Use Jellyfin image"));
|
|
||||||
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, JellyfinAction, "Jellyfin action reached");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var originalValue = ItemsProxyTransformer.GetAsset(byKind, kind, OriginalAction);
|
|
||||||
var hasOriginalValue = !string.IsNullOrWhiteSpace(originalValue);
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/Original", hasOriginalValue, hasOriginalValue, originalValue, hasOriginalValue ? "Original-language asset found" : "Original-language asset missing"));
|
|
||||||
if (hasOriginalValue)
|
|
||||||
{
|
|
||||||
var result = new ItemsProxyResolvedAsset(true, originalValue);
|
|
||||||
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Original-language asset found");
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Unknown", string.Empty, false, false, null, "Unsupported action token"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lang = action[LanguagePrefix.Length..];
|
|
||||||
var value = ItemsProxyTransformer.GetAsset(byKind, kind, lang);
|
|
||||||
var hasValue = !string.IsNullOrWhiteSpace(value);
|
|
||||||
attempts.Add(new DebugResolutionAttempt(action, "Asset", kind + "/" + lang, hasValue, hasValue, value, hasValue ? "Asset found" : "Asset missing"));
|
|
||||||
if (hasValue)
|
|
||||||
{
|
|
||||||
var result = new ItemsProxyResolvedAsset(true, value);
|
|
||||||
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), result, action, "Asset found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallback = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? new ItemsProxyResolvedAsset(false, null)
|
|
||||||
: new ItemsProxyResolvedAsset(true, string.Empty);
|
|
||||||
var reason = kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "No action had data; poster falls back to Jellyfin"
|
|
||||||
: "No action had data; image is cleared";
|
|
||||||
return new DebugAssetResolution(kind, actions.ToArray(), attempts.ToArray(), fallback, kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase) ? JellyfinAction : ClearAction, reason);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object[] BuildMissingDataReport(
|
private static object[] BuildMissingDataReport(
|
||||||
@@ -983,238 +938,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private UserRulesDocument GetRulesOrDefault(string userId)
|
private UserRulesDocument GetRulesOrDefault(string userId)
|
||||||
{
|
=> _store.GetUserRules(userId) ?? new UserRulesDocument { Enabled = false };
|
||||||
var existing = _store.GetUserRules(userId);
|
|
||||||
if (existing is not null)
|
|
||||||
{
|
|
||||||
existing.Categories ??= [];
|
|
||||||
existing.FallbackFieldActions = NormalizeActionLists(existing.FallbackFieldActions, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase));
|
|
||||||
existing.Categories = NormalizeCategories(existing.Categories, (Plugin.Instance?.Configuration?.Languages ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase));
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new UserRulesDocument
|
|
||||||
{
|
|
||||||
Enabled = false,
|
|
||||||
SortLocale = "Auto",
|
|
||||||
TrustTmdbCollections = true,
|
|
||||||
FallbackFieldActions = DefaultActionLists(),
|
|
||||||
Categories = []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UserCategoryRule[] NormalizeCategories(IEnumerable<UserCategoryRule>? categories, IReadOnlySet<string> allowedLanguages)
|
|
||||||
=> (categories ?? [])
|
|
||||||
.Select(category => new UserCategoryRule
|
|
||||||
{
|
|
||||||
Id = string.IsNullOrWhiteSpace(category.Id) ? Guid.NewGuid().ToString("N") : category.Id.Trim(),
|
|
||||||
Label = string.IsNullOrWhiteSpace(category.Label) ? "Category" : category.Label.Trim(),
|
|
||||||
CriteriaText = category.CriteriaText?.Trim() ?? string.Empty,
|
|
||||||
Requirements = NormalizeRequirements(category.Requirements),
|
|
||||||
MatchAllConditions = category.MatchAllConditions,
|
|
||||||
Scopes = (category.Scopes ?? [])
|
|
||||||
.Where(scope => scope is "M" or "S" or "C")
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.DefaultIfEmpty("M")
|
|
||||||
.ToArray(),
|
|
||||||
FieldActions = NormalizeFieldActions(category.FieldActions, allowedLanguages),
|
|
||||||
FieldActionLists = NormalizeActionLists(category.FieldActionLists, allowedLanguages, category.FieldActions)
|
|
||||||
})
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
private static UserCategoryRequirement[] NormalizeRequirements(IEnumerable<UserCategoryRequirement>? requirements)
|
|
||||||
=> (requirements ?? [])
|
|
||||||
.Select(requirement =>
|
|
||||||
{
|
|
||||||
var fields = NormalizeRequirementFields(requirement.Fields, requirement.Field);
|
|
||||||
return new UserCategoryRequirement
|
|
||||||
{
|
|
||||||
Fields = fields,
|
|
||||||
Field = fields.FirstOrDefault() ?? string.Empty,
|
|
||||||
UseFieldOr = requirement.UseFieldOr,
|
|
||||||
Relation = NormalizeRequirementRelation(requirement.Relation),
|
|
||||||
UseOr = requirement.UseOr,
|
|
||||||
Values = (requirement.Values ?? [])
|
|
||||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
||||||
.Select(v => v.Trim())
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.Where(requirement =>
|
|
||||||
requirement.Fields.Length > 0 &&
|
|
||||||
requirement.Relation.Length > 0 &&
|
|
||||||
requirement.Values.Length > 0)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
private static string[] NormalizeRequirementFields(IEnumerable<string>? fields, string? legacyField)
|
|
||||||
{
|
|
||||||
var requested = (fields ?? [])
|
|
||||||
.Append(legacyField ?? string.Empty)
|
|
||||||
.Select(NormalizeRequirementField)
|
|
||||||
.Where(field => field.Length > 0)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var result = new List<string>();
|
|
||||||
string? kind = null;
|
|
||||||
foreach (var field in RequirementFieldOrder)
|
|
||||||
{
|
|
||||||
if (!requested.Contains(field))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var currentKind = IsCountryRequirementField(field) ? "country" : "language";
|
|
||||||
kind ??= currentKind;
|
|
||||||
if (currentKind == kind)
|
|
||||||
result.Add(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeRequirementField(string? field)
|
|
||||||
{
|
|
||||||
var normalized = (field ?? string.Empty).Trim().ToLowerInvariant();
|
|
||||||
return normalized is "original_language" or "spoken_languages" or "audio_language" or "origin_countries" or "production_countries"
|
|
||||||
? normalized
|
|
||||||
: string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsCountryRequirementField(string field)
|
|
||||||
=> field.Equals("origin_countries", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
field.Equals("production_countries", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static string NormalizeRequirementRelation(string? relation)
|
|
||||||
{
|
|
||||||
var normalized = (relation ?? string.Empty).Trim().ToLowerInvariant();
|
|
||||||
return normalized is "is" or "is_not" or "contains" or "not_contains"
|
|
||||||
? normalized
|
|
||||||
: string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string[]> NormalizeActionLists(
|
|
||||||
IReadOnlyDictionary<string, string[]>? actions,
|
|
||||||
IReadOnlySet<string> allowedLanguages,
|
|
||||||
IReadOnlyDictionary<string, string>? legacyActions = null)
|
|
||||||
{
|
|
||||||
if (legacyActions is not null && LooksLikeDefaultActionLists(actions) && HasMeaningfulLegacyActions(legacyActions))
|
|
||||||
actions = null;
|
|
||||||
|
|
||||||
var result = DefaultActionLists();
|
|
||||||
foreach (var field in result.Keys.ToArray())
|
|
||||||
{
|
|
||||||
var raw = actions is not null && actions.TryGetValue(field, out var configured)
|
|
||||||
? configured
|
|
||||||
: LegacyActionToList(field, legacyActions);
|
|
||||||
result[field] = NormalizeActionList(field, raw, allowedLanguages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool LooksLikeDefaultActionLists(IReadOnlyDictionary<string, string[]>? actions)
|
|
||||||
=> actions is null ||
|
|
||||||
DefaultActionLists().Keys.All(field =>
|
|
||||||
actions.TryGetValue(field, out var list) &&
|
|
||||||
list.Length == 1 &&
|
|
||||||
list[0].Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
private static bool HasMeaningfulLegacyActions(IReadOnlyDictionary<string, string> legacyActions)
|
|
||||||
=> legacyActions.Values.Any(action =>
|
|
||||||
!string.IsNullOrWhiteSpace(action) &&
|
|
||||||
!action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
private static string[] LegacyActionToList(string field, IReadOnlyDictionary<string, string>? legacyActions)
|
|
||||||
{
|
|
||||||
if (legacyActions is null || !legacyActions.TryGetValue(field, out var action))
|
|
||||||
return [JellyfinAction];
|
|
||||||
if (string.IsNullOrWhiteSpace(action) || action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return [JellyfinAction];
|
|
||||||
return [action];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string> NormalizeFieldActions(
|
|
||||||
IReadOnlyDictionary<string, string>? actions,
|
|
||||||
IReadOnlySet<string> allowedLanguages)
|
|
||||||
{
|
|
||||||
var result = DefaultFieldActions();
|
|
||||||
if (actions is null)
|
|
||||||
return result;
|
|
||||||
|
|
||||||
foreach (var field in result.Keys.ToArray())
|
|
||||||
{
|
|
||||||
if (!actions.TryGetValue(field, out var raw))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
result[field] = NormalizeAction(field, raw, allowedLanguages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeAction(string field, string? raw, IReadOnlySet<string> allowedLanguages)
|
|
||||||
{
|
|
||||||
var action = (raw ?? string.Empty).Trim();
|
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
action.Equals(FallbackAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return action;
|
|
||||||
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return OriginalAction;
|
|
||||||
if ((field is OverviewField or TaglineField or LogoKind) &&
|
|
||||||
action.Equals(ClearAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return ClearAction;
|
|
||||||
if (action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var lang = action[LanguagePrefix.Length..].Trim();
|
|
||||||
var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (!string.IsNullOrWhiteSpace(canonical))
|
|
||||||
return LanguagePrefix + canonical;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FallbackAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string[] NormalizeActionList(string field, IEnumerable<string>? raw, IReadOnlySet<string> allowedLanguages)
|
|
||||||
{
|
|
||||||
var result = new List<string>();
|
|
||||||
foreach (var action in raw ?? [])
|
|
||||||
{
|
|
||||||
var normalized = NormalizeActionToken(field, action, allowedLanguages);
|
|
||||||
if (normalized.Length == 0)
|
|
||||||
continue;
|
|
||||||
if (result.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
result.Add(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RequiresJellyfinFallback(field))
|
|
||||||
{
|
|
||||||
if (!result.Contains(JellyfinAction, StringComparer.OrdinalIgnoreCase))
|
|
||||||
result.Add(JellyfinAction);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeActionToken(string field, string? raw, IReadOnlySet<string> allowedLanguages)
|
|
||||||
{
|
|
||||||
var action = (raw ?? string.Empty).Trim();
|
|
||||||
if (action.Equals("J", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return JellyfinAction;
|
|
||||||
if (action.Equals("O", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return OriginalAction;
|
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return JellyfinAction;
|
|
||||||
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return OriginalAction;
|
|
||||||
|
|
||||||
var lang = action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? action[LanguagePrefix.Length..].Trim()
|
|
||||||
: action;
|
|
||||||
var canonical = allowedLanguages.FirstOrDefault(l => l.Equals(lang, StringComparison.OrdinalIgnoreCase));
|
|
||||||
return string.IsNullOrWhiteSpace(canonical) ? string.Empty : LanguagePrefix + canonical;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IActionResult ServeEmbedded(string resource, string contentType)
|
private IActionResult ServeEmbedded(string resource, string contentType)
|
||||||
{
|
{
|
||||||
@@ -1250,10 +974,6 @@ public sealed class MultilangController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var tokenUserId = await GetUserIdFromJellyfinApiAsync(token).ConfigureAwait(false);
|
|
||||||
if (!string.IsNullOrWhiteSpace(tokenUserId))
|
|
||||||
return tokenUserId;
|
|
||||||
|
|
||||||
var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
|
var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
|
||||||
var deviceId = info?.DeviceId ?? string.Empty;
|
var deviceId = info?.DeviceId ?? string.Empty;
|
||||||
var session = await _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint).ConfigureAwait(false);
|
var session = await _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint).ConfigureAwait(false);
|
||||||
@@ -1274,54 +994,18 @@ public sealed class MultilangController : ControllerBase
|
|||||||
: null;
|
: 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()
|
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);
|
var userId = await GetUserIdAsync().ConfigureAwait(false);
|
||||||
if (string.IsNullOrWhiteSpace(userId) || !Guid.TryParseExact(userId, "N", out var guid))
|
return Guid.TryParse(userId, out var guid) &&
|
||||||
return false;
|
_userManager.GetUserById(guid)?.HasPermission(PermissionKind.IsAdministrator) == true;
|
||||||
|
}
|
||||||
|
|
||||||
var user = _userManager.GetUserById(guid);
|
private bool CanAccessItem(Guid itemId, string userId)
|
||||||
if (user is null)
|
{
|
||||||
return false;
|
var user = _userManager.GetUserById(Guid.Parse(userId));
|
||||||
|
return user is not null && !user.HasPermission(PermissionKind.IsDisabled) &&
|
||||||
if (TryGetIsAdmin(user, out var isAdmin))
|
_libraryManager.GetItemById(itemId)?.IsVisibleStandalone(user) == true;
|
||||||
return isAdmin;
|
|
||||||
|
|
||||||
var remoteEndpoint = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
|
|
||||||
var dto = _userManager.GetUserDto(user, remoteEndpoint);
|
|
||||||
return dto.Policy?.IsAdministrator == true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool FormBool(IFormCollection form, string key, bool fallback)
|
private static bool FormBool(IFormCollection form, string key, bool fallback)
|
||||||
@@ -1334,28 +1018,6 @@ public sealed class MultilangController : ControllerBase
|
|||||||
value.Equals("on", StringComparison.OrdinalIgnoreCase);
|
value.Equals("on", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetIsAdmin(object user, out bool isAdmin)
|
|
||||||
{
|
|
||||||
isAdmin = false;
|
|
||||||
|
|
||||||
var policy = user.GetType().GetProperty("Policy")?.GetValue(user);
|
|
||||||
var policyAdmin = policy?.GetType().GetProperty("IsAdministrator")?.GetValue(policy);
|
|
||||||
if (policyAdmin is bool isPolicyAdmin)
|
|
||||||
{
|
|
||||||
isAdmin = isPolicyAdmin;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var directAdmin = user.GetType().GetProperty("IsAdministrator")?.GetValue(user);
|
|
||||||
if (directAdmin is bool isDirectAdmin)
|
|
||||||
{
|
|
||||||
isAdmin = isDirectAdmin;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? GetTokenFromRequest()
|
private string? GetTokenFromRequest()
|
||||||
{
|
{
|
||||||
var req = HttpContext?.Request;
|
var req = HttpContext?.Request;
|
||||||
@@ -1430,8 +1092,16 @@ public sealed class AdminConfigDto
|
|||||||
|
|
||||||
public int ItemsProxyCacheMaxMiB { get; set; }
|
public int ItemsProxyCacheMaxMiB { get; set; }
|
||||||
|
|
||||||
|
public bool PrecacheMovieLibraries { get; set; } = true;
|
||||||
|
|
||||||
|
public bool PrecacheTvShowLibraries { get; set; } = true;
|
||||||
|
|
||||||
public string AssetStorageMode { get; set; } = "url";
|
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 SortArticleEntry[] ArticleEntries { get; set; } = [];
|
||||||
|
|
||||||
public bool EnableLogging { get; set; }
|
public bool EnableLogging { get; set; }
|
||||||
@@ -1452,7 +1122,11 @@ public sealed class AdminConfigDto
|
|||||||
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
|
ItemsProxyCacheThresholdMs = cfg.ItemsProxyCacheThresholdMs,
|
||||||
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
|
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
|
||||||
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
|
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
|
||||||
|
PrecacheMovieLibraries = cfg.PrecacheMovieLibraries,
|
||||||
|
PrecacheTvShowLibraries = cfg.PrecacheTvShowLibraries,
|
||||||
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
|
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
|
||||||
|
IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting,
|
||||||
|
JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback,
|
||||||
ArticleEntries = cfg.ArticleEntries,
|
ArticleEntries = cfg.ArticleEntries,
|
||||||
EnableLogging = cfg.EnableLogging,
|
EnableLogging = cfg.EnableLogging,
|
||||||
VerboseLogging = cfg.VerboseLogging,
|
VerboseLogging = cfg.VerboseLogging,
|
||||||
@@ -1474,7 +1148,11 @@ public sealed class AdminConfigDto
|
|||||||
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
|
ItemsProxyCacheThresholdMs = Math.Max(0, ItemsProxyCacheThresholdMs),
|
||||||
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
|
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
|
||||||
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
|
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
|
||||||
|
PrecacheMovieLibraries = PrecacheMovieLibraries,
|
||||||
|
PrecacheTvShowLibraries = PrecacheTvShowLibraries,
|
||||||
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
|
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
|
||||||
|
IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting,
|
||||||
|
JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty,
|
||||||
ArticleEntries = ArticleEntries
|
ArticleEntries = ArticleEntries
|
||||||
.Where(e => !string.IsNullOrWhiteSpace(e.Language))
|
.Where(e => !string.IsNullOrWhiteSpace(e.Language))
|
||||||
.Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply })
|
.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 int ItemsProxyCacheMaxMiB { get; set; } = 50;
|
||||||
|
|
||||||
|
public bool PrecacheMovieLibraries { get; set; } = true;
|
||||||
|
|
||||||
|
public bool PrecacheTvShowLibraries { get; set; } = true;
|
||||||
|
|
||||||
public string AssetStorageMode { get; set; } = "url";
|
public string AssetStorageMode { get; set; } = "url";
|
||||||
|
|
||||||
|
public bool IgnoreArticlesWhenSorting { get; set; } = true;
|
||||||
|
|
||||||
|
public string JellyfinTitleLanguageFallback { get; set; } = string.Empty;
|
||||||
|
|
||||||
[XmlArray("SortArticles")]
|
[XmlArray("SortArticles")]
|
||||||
[XmlArrayItem("SortArticleEntry")]
|
[XmlArrayItem("SortArticleEntry")]
|
||||||
public SortArticleEntry[] ArticleEntries { get; set; } = [];
|
public SortArticleEntry[] ArticleEntries { get; set; } = [];
|
||||||
|
|||||||
@@ -28,16 +28,25 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="ml-section">
|
<fieldset class="ml-section">
|
||||||
<legend>Languages</legend>
|
<legend>Languages to fetch</legend>
|
||||||
<div class="inputContainer">
|
<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">
|
<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 class="fieldDescription">Use BCP-47 language tags. These are the languages refresh jobs fetch and user rules can select.</div>
|
||||||
</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">
|
<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">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 id="ml-article-list" class="ml-articles-list"></div>
|
||||||
<div class="ml-articles-actions">
|
<div class="ml-articles-actions">
|
||||||
<button id="ml-article-add" is="emby-button" type="button" class="raised"><span>Add language</span></button>
|
<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">
|
<fieldset class="ml-section">
|
||||||
<legend>Refresh activity</legend>
|
<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>
|
<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>
|
<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>
|
<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>
|
<div id="ml-provider-http" class="ml-cache-entries"></div>
|
||||||
|
</details>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="ml-section">
|
<fieldset class="ml-section">
|
||||||
<legend>Items cache</legend>
|
<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">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="ml-cache-threshold">ItemsProxy cache threshold ms</label>
|
<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">
|
<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>
|
<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">
|
<input id="ml-cache-max" type="number" class="emby-input ml-input" min="1" step="1">
|
||||||
</div>
|
</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">
|
<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-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>
|
<button id="ml-cache-clear" is="emby-button" type="button" class="raised ml-danger"><span>Clear cache</span></button>
|
||||||
</div>
|
</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>
|
<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>
|
<div id="ml-proxy-requests" class="ml-cache-entries"></div>
|
||||||
|
</details>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="ml-section">
|
<fieldset class="ml-section">
|
||||||
@@ -126,6 +157,22 @@
|
|||||||
<div id="ml-storage-info" class="fieldDescription"></div>
|
<div id="ml-storage-info" class="fieldDescription"></div>
|
||||||
</fieldset>
|
</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 user’s 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-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>
|
<button id="ml-reload" is="emby-button" type="button" class="raised"><span>Reload</span></button>
|
||||||
</form>
|
</form>
|
||||||
@@ -179,7 +226,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var M = window.Multilang;
|
var M = window.Multilang;
|
||||||
var state = { config: null, providers: [], articles: {} };
|
var state = { config: null, providers: [], articles: {}, metadataLanguages: [] };
|
||||||
var page = document.getElementById("ml-admin");
|
var page = document.getElementById("ml-admin");
|
||||||
var form = document.getElementById("ml-admin-form");
|
var form = document.getElementById("ml-admin-form");
|
||||||
var status = document.getElementById("ml-status");
|
var status = document.getElementById("ml-status");
|
||||||
@@ -226,6 +273,38 @@
|
|||||||
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
|
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() {
|
function syncProviderSourceWidth() {
|
||||||
var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source"));
|
var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source"));
|
||||||
if (!sources.length) return;
|
if (!sources.length) return;
|
||||||
@@ -238,7 +317,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderProviderInputs() {
|
function renderProviderInputs() {
|
||||||
providerInputs.replaceChildren();
|
M.replaceChildren(providerInputs);
|
||||||
state.providers.forEach(function (provider) {
|
state.providers.forEach(function (provider) {
|
||||||
var id = providerId(provider);
|
var id = providerId(provider);
|
||||||
var keyField = providerKeyField(provider);
|
var keyField = providerKeyField(provider);
|
||||||
@@ -292,7 +371,7 @@
|
|||||||
.map(providerId)
|
.map(providerId)
|
||||||
.filter(function (id) { return providerSupports(providerById(id), bucket) && orderFor(id, bucket) >= 0; })
|
.filter(function (id) { return providerSupports(providerById(id), bucket) && orderFor(id, bucket) >= 0; })
|
||||||
.sort(function (a, b) { return orderFor(a, bucket) - orderFor(b, bucket); });
|
.sort(function (a, b) { return orderFor(a, bucket) - orderFor(b, bucket); });
|
||||||
list.replaceChildren();
|
M.replaceChildren(list);
|
||||||
ids.forEach(function (id) { list.appendChild(makeProviderChip(id)); });
|
ids.forEach(function (id) { list.appendChild(makeProviderChip(id)); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +395,7 @@
|
|||||||
var alwaysInput = make("input", "ml-articles-apply-toggle");
|
var alwaysInput = make("input", "ml-articles-apply-toggle");
|
||||||
alwaysInput.type = "checkbox";
|
alwaysInput.type = "checkbox";
|
||||||
alwaysInput.checked = !!alwaysApply;
|
alwaysInput.checked = !!alwaysApply;
|
||||||
always.append(alwaysInput, make("span", "", "Always"));
|
always.append(alwaysInput, make("span", "", "Always ignore"));
|
||||||
var remove = make("button", "raised ml-articles-remove");
|
var remove = make("button", "raised ml-articles-remove");
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.setAttribute("is", "emby-button");
|
remove.setAttribute("is", "emby-button");
|
||||||
@@ -333,7 +412,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderArticles() {
|
function renderArticles() {
|
||||||
articleList.replaceChildren();
|
M.replaceChildren(articleList);
|
||||||
var configured = new Map();
|
var configured = new Map();
|
||||||
(state.config.ArticleEntries || []).forEach(function (entry) {
|
(state.config.ArticleEntries || []).forEach(function (entry) {
|
||||||
var lang = normalizeLang(entry.Language || entry.language);
|
var lang = normalizeLang(entry.Language || entry.language);
|
||||||
@@ -382,7 +461,11 @@
|
|||||||
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0);
|
state.config.ItemsProxyCacheThresholdMs = Number(document.getElementById("ml-cache-threshold").value || 0);
|
||||||
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
|
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
|
||||||
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").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.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.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked;
|
||||||
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
|
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
|
||||||
state.config.CleanupDataOnUninstall = document.getElementById("ml-cleanup-uninstall").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-threshold").value = state.config.ItemsProxyCacheThresholdMs ?? 1000;
|
||||||
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
|
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
|
||||||
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50;
|
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-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-enabled").checked = !!state.config.EnableLogging;
|
||||||
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
|
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
|
||||||
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
|
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
|
||||||
renderProviderInputs();
|
renderProviderInputs();
|
||||||
renderProviderBuckets();
|
renderProviderBuckets();
|
||||||
|
renderJellyfinTitleLanguages();
|
||||||
|
syncArticleSortingControls();
|
||||||
renderArticles();
|
renderArticles();
|
||||||
loadCacheDiagnostics();
|
loadCacheDiagnostics();
|
||||||
loadRefreshDiagnostics();
|
loadRefreshDiagnostics();
|
||||||
loadExportInfo();
|
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) {
|
function renderCacheEntries(entries, error) {
|
||||||
var root = document.getElementById("ml-cache-entries");
|
var root = document.getElementById("ml-cache-entries");
|
||||||
root.replaceChildren();
|
M.replaceChildren(root);
|
||||||
if (error) {
|
if (error) {
|
||||||
|
setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses");
|
||||||
root.appendChild(make("div", "ml-error", error));
|
root.appendChild(make("div", "ml-error", error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDiagnosticsSummary("ml-cache-entries-summary", "Cached responses (" + (entries ? entries.length : 0) + ")");
|
||||||
if (!entries || entries.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
root.appendChild(make("div", "ml-muted", "No cache entries."));
|
root.appendChild(make("div", "ml-muted", "No cache entries."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
var url = entry.Url || entry.url || "";
|
var url = M.prop(entry, "Url", "url", "");
|
||||||
root.appendChild(makeDiagnosticRow(url, [
|
root.appendChild(makeDiagnosticRow(url, [
|
||||||
"cached",
|
"cached",
|
||||||
(entry.ItemCount || entry.itemCount || 0) + " items",
|
M.prop(entry, "ItemCount", "itemCount", 0) + " items",
|
||||||
(entry.DurationMs || entry.durationMs || 0) + " ms",
|
M.prop(entry, "DurationMs", "durationMs", 0) + " ms",
|
||||||
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB",
|
(M.prop(entry, "SizeBytes", "sizeBytes", 0) / 1024).toFixed(1) + " KiB",
|
||||||
(entry.AgeSeconds || entry.ageSeconds || 0) + " s old"
|
makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0))
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -451,28 +576,35 @@
|
|||||||
|
|
||||||
function renderProxyRequests(entries, error) {
|
function renderProxyRequests(entries, error) {
|
||||||
var root = document.getElementById("ml-proxy-requests");
|
var root = document.getElementById("ml-proxy-requests");
|
||||||
root.replaceChildren();
|
M.replaceChildren(root);
|
||||||
if (error) {
|
if (error) {
|
||||||
|
setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests");
|
||||||
root.appendChild(make("div", "ml-error", error));
|
root.appendChild(make("div", "ml-error", error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDiagnosticsSummary("ml-proxy-requests-summary", "Recent proxy requests (" + (entries ? entries.length : 0) + " of last 100)");
|
||||||
if (!entries || entries.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests."));
|
root.appendChild(make("div", "ml-muted", "No recent ItemsProxy requests."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach(function (entry) {
|
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, [
|
root.appendChild(makeDiagnosticRow(url, [
|
||||||
(entry.CacheHit || entry.cacheHit) ? "hit" : ((entry.CacheStored || entry.cacheStored) ? "stored" : "miss"),
|
cacheState,
|
||||||
"HTTP " + (entry.StatusCode || entry.statusCode || 0),
|
"HTTP " + M.prop(entry, "StatusCode", "statusCode", 0),
|
||||||
(entry.ItemCount || entry.itemCount || 0) + " items",
|
M.prop(entry, "ItemCount", "itemCount", 0) + " items",
|
||||||
"total " + (entry.TotalMs || entry.totalMs || 0) + " ms",
|
"total " + M.prop(entry, "TotalMs", "totalMs", 0) + " ms",
|
||||||
"upstream " + (entry.UpstreamMs || entry.upstreamMs || 0) + " ms",
|
"upstream " + M.prop(entry, "UpstreamMs", "upstreamMs", 0) + " ms",
|
||||||
"transform " + (entry.TransformMs || entry.transformMs || 0) + " ms",
|
"transform " + M.prop(entry, "TransformMs", "transformMs", 0) + " ms",
|
||||||
((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB",
|
(M.prop(entry, "SizeBytes", "sizeBytes", 0) / 1024).toFixed(1) + " KiB",
|
||||||
(entry.AgeSeconds || entry.ageSeconds || 0) + " s old"
|
makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0))
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -481,7 +613,9 @@
|
|||||||
var row = make("div", "ml-diagnostic-row");
|
var row = make("div", "ml-diagnostic-row");
|
||||||
var top = make("div", "ml-diagnostic-meta");
|
var top = make("div", "ml-diagnostic-meta");
|
||||||
parts.forEach(function (part, index) {
|
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);
|
top.appendChild(chip);
|
||||||
});
|
});
|
||||||
var urlEl = make("div", "ml-cache-url", url);
|
var urlEl = make("div", "ml-cache-url", url);
|
||||||
@@ -499,6 +633,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadCacheDiagnostics() {
|
function loadCacheDiagnostics() {
|
||||||
|
ensureAgeTicker();
|
||||||
loadCacheEntries();
|
loadCacheEntries();
|
||||||
loadProxyRequests();
|
loadProxyRequests();
|
||||||
}
|
}
|
||||||
@@ -523,15 +658,28 @@
|
|||||||
return new Date(value * 1000).toLocaleString();
|
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) {
|
function renderRefreshDiagnostics(data, error) {
|
||||||
var scanRoot = document.getElementById("ml-refresh-scan");
|
var scanRoot = document.getElementById("ml-refresh-scan");
|
||||||
var queueRoot = document.getElementById("ml-refresh-queue");
|
var queueRoot = document.getElementById("ml-refresh-queue");
|
||||||
var httpRoot = document.getElementById("ml-provider-http");
|
var httpRoot = document.getElementById("ml-provider-http");
|
||||||
scanRoot.replaceChildren();
|
M.replaceChildren(scanRoot);
|
||||||
queueRoot.replaceChildren();
|
M.replaceChildren(queueRoot);
|
||||||
httpRoot.replaceChildren();
|
M.replaceChildren(httpRoot);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan");
|
||||||
scanRoot.appendChild(make("div", "ml-error", error));
|
scanRoot.appendChild(make("div", "ml-error", error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -541,53 +689,79 @@
|
|||||||
var queue = prop(data, "Queue", "queue", {});
|
var queue = prop(data, "Queue", "queue", {});
|
||||||
var providerHttp = prop(data, "ProviderHttp", "providerHttp", {});
|
var providerHttp = prop(data, "ProviderHttp", "providerHttp", {});
|
||||||
var running = !!prop(scan, "Running", "running", false);
|
var running = !!prop(scan, "Running", "running", false);
|
||||||
scanRoot.appendChild(makeDiagnosticRow("Last scan started: " + formatUnixTime(prop(data, "LastScanStarted", "lastScanStarted", 0)), [
|
var scanStartedAt = Number(prop(scan, "StartedAt", "startedAt", 0) || 0);
|
||||||
running ? "running" : "idle",
|
var scanSuccess = !!prop(scan, "Success", "success", false);
|
||||||
String(prop(scan, "Mode", "mode", "") || "none"),
|
if (!scanStartedAt) {
|
||||||
String(prop(scan, "Source", "source", "") || "none"),
|
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan");
|
||||||
"items " + Number(prop(scan, "Items", "items", 0) || 0),
|
scanRoot.appendChild(make("div", "ml-muted", "No scan diagnostics are available since Jellyfin last started."));
|
||||||
"due " + Number(prop(scan, "Due", "due", 0) || 0),
|
} else {
|
||||||
"refreshed " + Number(prop(scan, "Refreshed", "refreshed", 0) || 0),
|
var result = running ? "running" : (scanSuccess ? "completed" : "failed");
|
||||||
"skipped " + (Number(prop(scan, "SkippedNotDue", "skippedNotDue", 0) || 0) + Number(prop(scan, "SkippedNoData", "skippedNoData", 0) || 0)),
|
setDiagnosticsSummary("ml-refresh-scan-summary", "Latest scan (" + result + ")");
|
||||||
"new " + Number(prop(scan, "New", "new", 0) || 0),
|
scanRoot.appendChild(makeDiagnosticRow("Started: " + formatUnixTime(scanStartedAt), [
|
||||||
"deleted " + Number(prop(scan, "Deleted", "deleted", 0) || 0),
|
running ? "running now" : "not running",
|
||||||
"current " + Number(prop(scan, "CurrentIndex", "currentIndex", 0) || 0) + "/" + Number(prop(scan, "Items", "items", 0) || 0),
|
"result: " + result,
|
||||||
String(prop(scan, "CurrentAction", "currentAction", "") || "idle"),
|
"mode: " + scanMode(prop(scan, "Mode", "mode", "")),
|
||||||
Number(prop(scan, "ElapsedMs", "elapsedMs", 0) || 0) + " ms"
|
"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", "") || "");
|
var currentItem = String(prop(scan, "CurrentItemId", "currentItemId", "") || "");
|
||||||
if (currentItem) {
|
if (currentItem) {
|
||||||
scanRoot.appendChild(makeDiagnosticRow("Current item: " + currentItem, [
|
scanRoot.appendChild(makeDiagnosticRow(String(prop(scan, "CurrentName", "currentName", "") || currentItem), [
|
||||||
String(prop(scan, "CurrentName", "currentName", "") || ""),
|
running ? "current item" : "last item processed",
|
||||||
String(prop(scan, "CurrentKind", "currentKind", "") || "")
|
"kind: " + String(prop(scan, "CurrentKind", "currentKind", "") || "unknown"),
|
||||||
|
"action: " + String(prop(scan, "CurrentAction", "currentAction", "") || "unknown"),
|
||||||
|
"ID: " + currentItem
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
var scanError = String(prop(scan, "Error", "error", "") || "");
|
var scanError = String(prop(scan, "Error", "error", "") || "");
|
||||||
if (scanError) scanRoot.appendChild(make("div", "ml-error", scanError));
|
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 queued = prop(queue, "Queued", "queued", []) || [];
|
||||||
var recent = prop(queue, "Recent", "recent", []) || [];
|
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", "")), [
|
queueRoot.appendChild(makeDiagnosticRow("Active item: " + String(prop(active, "ItemId", "itemId", "")), [
|
||||||
"active",
|
"running now",
|
||||||
String(prop(active, "SourceTier", "sourceTier", "")),
|
"source: " + String(prop(active, "SourceTier", "sourceTier", "")),
|
||||||
String(prop(active, "WorkClass", "workClass", "")),
|
"type: " + String(prop(active, "WorkClass", "workClass", "")),
|
||||||
String(prop(active, "JobType", "jobType", ""))
|
"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", [
|
if (recent.length > 0) queueRoot.appendChild(diagnosticHeading("Recently completed refreshes (not pending)"));
|
||||||
"queued",
|
|
||||||
String(Number(prop(queue, "QueuedCount", "queuedCount", queued.length) || 0))
|
|
||||||
]));
|
|
||||||
recent.slice(0, 10).forEach(function (item) {
|
recent.slice(0, 10).forEach(function (item) {
|
||||||
queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [
|
queueRoot.appendChild(makeDiagnosticRow("Item: " + String(prop(item, "ItemId", "itemId", "")), [
|
||||||
prop(item, "Ok", "ok", false) ? "ok" : "failed",
|
prop(item, "Ok", "ok", false) ? "completed" : "failed",
|
||||||
String(prop(item, "SourceTier", "sourceTier", "")),
|
"source: " + String(prop(item, "SourceTier", "sourceTier", "")),
|
||||||
String(prop(item, "WorkClass", "workClass", "")),
|
"type: " + String(prop(item, "WorkClass", "workClass", "")),
|
||||||
String(prop(item, "JobType", "jobType", "")),
|
"refresh: " + String(prop(item, "JobType", "jobType", "")),
|
||||||
Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms",
|
"duration: " + Number(prop(item, "DurationMs", "durationMs", 0) || 0) + " ms",
|
||||||
formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0))
|
"finished: " + formatUnixTime(prop(item, "FinishedAt", "finishedAt", 0))
|
||||||
]));
|
]));
|
||||||
var itemError = String(prop(item, "Error", "error", "") || "");
|
var itemError = String(prop(item, "Error", "error", "") || "");
|
||||||
if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError));
|
if (itemError) queueRoot.appendChild(make("div", "ml-error", itemError));
|
||||||
@@ -595,23 +769,26 @@
|
|||||||
|
|
||||||
var counts = prop(providerHttp, "Counts", "counts", []) || [];
|
var counts = prop(providerHttp, "Counts", "counts", []) || [];
|
||||||
var providerRecent = prop(providerHttp, "Recent", "recent", []) || [];
|
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) {
|
if (counts.length === 0 && providerRecent.length === 0) {
|
||||||
httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup."));
|
httpRoot.appendChild(make("div", "ml-muted", "No provider HTTP activity recorded since plugin startup."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (counts.length > 0) httpRoot.appendChild(diagnosticHeading("Outcome totals since plugin startup"));
|
||||||
counts.forEach(function (count) {
|
counts.forEach(function (count) {
|
||||||
httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [
|
httpRoot.appendChild(makeDiagnosticRow(String(prop(count, "Provider", "provider", "")), [
|
||||||
"count",
|
"final outcome",
|
||||||
String(prop(count, "Outcome", "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) {
|
providerRecent.slice(0, 10).forEach(function (entry) {
|
||||||
httpRoot.appendChild(makeDiagnosticRow(String(prop(entry, "Url", "url", "")), [
|
httpRoot.appendChild(makeDiagnosticRow(String(prop(entry, "Url", "url", "")), [
|
||||||
String(prop(entry, "Provider", "provider", "")),
|
String(prop(entry, "Provider", "provider", "")),
|
||||||
String(prop(entry, "Outcome", "outcome", "")),
|
String(prop(entry, "Outcome", "outcome", "")),
|
||||||
"attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0),
|
"completed on attempt " + Number(prop(entry, "Attempt", "attempt", 0) || 0),
|
||||||
formatUnixTime(prop(entry, "At", "at", 0))
|
"at " + formatUnixTime(prop(entry, "At", "at", 0))
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -716,11 +893,13 @@
|
|||||||
var all = await Promise.all([
|
var all = await Promise.all([
|
||||||
M.get("/Multilang/Providers"),
|
M.get("/Multilang/Providers"),
|
||||||
M.get("/Multilang/ArticleBuiltins"),
|
M.get("/Multilang/ArticleBuiltins"),
|
||||||
|
M.get("/Multilang/MetadataLanguages"),
|
||||||
M.get("/Multilang/AdminConfig")
|
M.get("/Multilang/AdminConfig")
|
||||||
]);
|
]);
|
||||||
state.providers = all[0] || [];
|
state.providers = all[0] || [];
|
||||||
state.articles = all[1] || {};
|
state.articles = all[1] || {};
|
||||||
state.config = all[2] || {};
|
state.metadataLanguages = all[2] || [];
|
||||||
|
state.config = all[3] || {};
|
||||||
loadIntoForm();
|
loadIntoForm();
|
||||||
M.setStatus(status, "");
|
M.setStatus(status, "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -867,6 +1046,14 @@
|
|||||||
document.getElementById("ml-cache-refresh").addEventListener("click", loadCacheDiagnostics);
|
document.getElementById("ml-cache-refresh").addEventListener("click", loadCacheDiagnostics);
|
||||||
document.getElementById("ml-cache-clear").addEventListener("click", clearCacheEntries);
|
document.getElementById("ml-cache-clear").addEventListener("click", clearCacheEntries);
|
||||||
document.getElementById("ml-refresh-activity-refresh").addEventListener("click", loadRefreshDiagnostics);
|
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-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-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"); });
|
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 () {
|
document.getElementById("ml-log-verbose").addEventListener("change", function () {
|
||||||
if (this.checked) document.getElementById("ml-log-enabled").checked = true;
|
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-add").addEventListener("click", function () { addArticleRow("", "", false, false); });
|
||||||
document.getElementById("ml-article-repair").addEventListener("click", renderArticles);
|
document.getElementById("ml-article-repair").addEventListener("click", renderArticles);
|
||||||
document.getElementById("ml-article-reset").addEventListener("click", function () {
|
document.getElementById("ml-article-reset").addEventListener("click", function () {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<label class="ml-inline-check">
|
<label class="ml-inline-check">
|
||||||
<span>View:</span>
|
<span>View:</span>
|
||||||
<select id="ml-debug-mode" class="emby-select ml-select">
|
<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>
|
<option value="stored">Stored facts and translations</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -44,12 +44,17 @@
|
|||||||
return match ? decodeURIComponent(match[1]) : "";
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function targetUserId() {
|
||||||
|
return new URLSearchParams(location.search).get("userId") || "";
|
||||||
|
}
|
||||||
|
|
||||||
function endpoint() {
|
function endpoint() {
|
||||||
var id = encodeURIComponent(itemId());
|
var id = encodeURIComponent(itemId());
|
||||||
var locale = document.documentElement.lang || navigator.language || "";
|
var locale = document.documentElement.lang || navigator.language || "";
|
||||||
var suffix = locale ? "?mlLocale=" + encodeURIComponent(locale) : "";
|
var suffix = locale ? "?mlLocale=" + encodeURIComponent(locale) : "";
|
||||||
return mode.value === "stored"
|
if (mode.value === "stored") return "/Multilang/Debug/" + id;
|
||||||
? "/Multilang/Debug/" + id
|
return targetUserId()
|
||||||
|
? "/Multilang/Debug/" + id + "/" + encodeURIComponent(targetUserId()) + suffix
|
||||||
: "/Multilang/Debug/" + id + "/self" + suffix;
|
: "/Multilang/Debug/" + id + "/self" + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +185,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(data) {
|
function renderSummary(data) {
|
||||||
summary.replaceChildren();
|
M.replaceChildren(summary);
|
||||||
if (mode.value !== "resolved") return;
|
if (mode.value !== "resolved") return;
|
||||||
var top = make("div", "ml-diagnostic-meta");
|
var top = make("div", "ml-diagnostic-meta");
|
||||||
top.append(
|
top.append(
|
||||||
@@ -193,7 +198,7 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
M.setStatus(status, "Loading...");
|
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 {
|
try {
|
||||||
var data = await M.get(endpoint());
|
var data = await M.get(endpoint());
|
||||||
lastJson = JSON.stringify(data, null, 2);
|
lastJson = JSON.stringify(data, null, 2);
|
||||||
@@ -202,7 +207,7 @@
|
|||||||
M.setStatus(status, "Loaded.");
|
M.setStatus(status, "Loaded.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastJson = "";
|
lastJson = "";
|
||||||
summary.replaceChildren();
|
M.replaceChildren(summary);
|
||||||
output.textContent = authHint(err);
|
output.textContent = authHint(err);
|
||||||
M.setStatus(status, err.message, true);
|
M.setStatus(status, err.message, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,10 @@ select.emby-select option:checked {
|
|||||||
opacity: .7;
|
opacity: .7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-disabled {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-error {
|
.ml-error {
|
||||||
color: #ff9b9b;
|
color: #ff9b9b;
|
||||||
}
|
}
|
||||||
@@ -760,6 +764,27 @@ select.emby-select option:checked {
|
|||||||
font-weight: 600;
|
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 {
|
.ml-diagnostic-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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) {
|
function authHeaders(contentType) {
|
||||||
const qs = new URLSearchParams(location.search);
|
const qs = new URLSearchParams(location.search);
|
||||||
const t = token() || qs.get("token") || qs.get("api_key") || "";
|
const t = token() || qs.get("token") || qs.get("api_key") || "";
|
||||||
const h = {};
|
const h = {};
|
||||||
if (contentType) h["Content-Type"] = contentType;
|
if (contentType) h["Content-Type"] = contentType;
|
||||||
if (t) {
|
if (t) {
|
||||||
|
h.Authorization = `MediaBrowser Token="${t}"`;
|
||||||
h["X-Emby-Token"] = t;
|
h["X-Emby-Token"] = t;
|
||||||
h["X-MediaBrowser-Token"] = t;
|
h["X-MediaBrowser-Token"] = t;
|
||||||
}
|
}
|
||||||
return h;
|
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) {
|
async function request(path, options) {
|
||||||
const response = await fetch(`${basePath()}${path}`, {
|
const response = await fetch(`${basePath()}${path}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: headers(),
|
headers: authHeaders("application/json"),
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -95,41 +94,9 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chip(label) {
|
function replaceChildren(parent, ...children) {
|
||||||
const el = document.createElement("span");
|
parent.textContent = "";
|
||||||
el.className = "ml-chip";
|
parent.append(...children);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const staticData = {
|
const staticData = {
|
||||||
@@ -142,6 +109,7 @@
|
|||||||
get: (path) => request(path),
|
get: (path) => request(path),
|
||||||
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
|
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
basePath,
|
basePath,
|
||||||
|
debugUrl,
|
||||||
authHeaders,
|
authHeaders,
|
||||||
splitList,
|
splitList,
|
||||||
setStatus,
|
setStatus,
|
||||||
@@ -150,10 +118,7 @@
|
|||||||
showModal,
|
showModal,
|
||||||
hideModals,
|
hideModals,
|
||||||
downloadBlob,
|
downloadBlob,
|
||||||
chip,
|
replaceChildren,
|
||||||
bucketValues,
|
|
||||||
renderBucket,
|
|
||||||
wireBucket,
|
|
||||||
staticData
|
staticData
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -39,6 +39,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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-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>
|
<button id="ml-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button>
|
||||||
</form>
|
</form>
|
||||||
@@ -97,8 +109,19 @@
|
|||||||
var ISO3166 = M.staticData.iso3166;
|
var ISO3166 = M.staticData.iso3166;
|
||||||
var LANG_REGION_COMBOS = M.staticData.langRegionCombos;
|
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) {
|
function $(id) {
|
||||||
return document.getElementById(id);
|
return page.querySelector("#" + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function make(tag, className, text) {
|
function make(tag, className, text) {
|
||||||
@@ -132,18 +155,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function categoryId(category) {
|
function categoryId(category) {
|
||||||
return category.Id || category.id || "";
|
return category.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryLabel(category, index) {
|
function categoryLabel(category, index) {
|
||||||
var label = String(category.Label || category.label || "").trim();
|
var label = String(category.Label || "").trim();
|
||||||
return label || categoryPlaceholder(index);
|
return label || categoryPlaceholder(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryPlaceholder(index) {
|
function categoryPlaceholder(index) {
|
||||||
var used = {};
|
var used = {};
|
||||||
categories().forEach(function (category) {
|
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;
|
if (label) used[label] = true;
|
||||||
});
|
});
|
||||||
var n = Math.max(1, index + 1);
|
var n = Math.max(1, index + 1);
|
||||||
@@ -151,11 +174,10 @@
|
|||||||
return "Category " + n;
|
return "Category " + n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newCategory(label, criteria) {
|
function newCategory(label) {
|
||||||
return {
|
return {
|
||||||
Id: randomId(),
|
Id: randomId(),
|
||||||
Label: label || "",
|
Label: label || "",
|
||||||
CriteriaText: criteria || "",
|
|
||||||
Requirements: [emptyRequirement()],
|
Requirements: [emptyRequirement()],
|
||||||
MatchAllConditions: true,
|
MatchAllConditions: true,
|
||||||
Scopes: ["M", "S", "C"],
|
Scopes: ["M", "S", "C"],
|
||||||
@@ -164,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyRequirement() {
|
function emptyRequirement() {
|
||||||
return { Field: "", Fields: [], UseFieldOr: false, Relation: "", UseOr: false, Values: [] };
|
return { Fields: [], UseFieldOr: false, Relation: "", UseOr: false, Values: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeActionValue(fieldId, value) {
|
function normalizeActionValue(fieldId, value) {
|
||||||
@@ -177,13 +199,6 @@
|
|||||||
return canonical ? "Language:" + canonical : "";
|
return canonical ? "Language:" + canonical : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyActionToList(fieldId, legacy) {
|
|
||||||
if (!legacy || legacy.toLowerCase() === "fallback") {
|
|
||||||
return ["Jellyfin"];
|
|
||||||
}
|
|
||||||
return [legacy];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeActionList(fieldId, values) {
|
function normalizeActionList(fieldId, values) {
|
||||||
var field = fields.find(function (f) { return f.id === fieldId; });
|
var field = fields.find(function (f) { return f.id === fieldId; });
|
||||||
var result = [];
|
var result = [];
|
||||||
@@ -199,64 +214,31 @@
|
|||||||
return result.length || !(field && field.lockedJ) ? result : ["Jellyfin"];
|
return result.length || !(field && field.lockedJ) ? result : ["Jellyfin"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeActionMap(source, legacy) {
|
function normalizeActionMap(source) {
|
||||||
if (legacy && looksLikeDefaultActionMap(source) && hasMeaningfulLegacyActions(legacy)) {
|
|
||||||
source = null;
|
|
||||||
}
|
|
||||||
var result = {};
|
var result = {};
|
||||||
fields.forEach(function (field) {
|
fields.forEach(function (field) {
|
||||||
var values = source && (source[field.id] || source[field.label]);
|
result[field.id] = normalizeActionList(field.id, source[field.id]);
|
||||||
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"]);
|
|
||||||
});
|
});
|
||||||
return result;
|
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() {
|
function normalizeRules() {
|
||||||
state.rules = state.rules || {};
|
state.rules.Categories.forEach(function (category) {
|
||||||
state.rules.Categories = (state.rules.Categories || []).filter(function (category) {
|
category.Requirements = normalizeRequirements(category.Requirements);
|
||||||
var id = categoryId(category).toLowerCase();
|
category.FieldActionLists = normalizeActionMap(category.FieldActionLists);
|
||||||
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.FallbackFieldActions = normalizeActionMap(state.rules.FallbackFieldActions || state.rules.fallbackFieldActions, null);
|
state.rules.FallbackFieldActions = normalizeActionMap(state.rules.FallbackFieldActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRequirements(requirements) {
|
function normalizeRequirements(requirements) {
|
||||||
var normalized = (requirements || []).map(function (requirement) {
|
var normalized = (requirements || []).map(function (requirement) {
|
||||||
var fields = normalizeRequirementFields(requirement.Fields || requirement.fields, requirement.Field || requirement.field);
|
var fields = normalizeRequirementFields(requirement.Fields);
|
||||||
return {
|
return {
|
||||||
Field: fields[0] || "",
|
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
UseFieldOr: requirement.UseFieldOr === true || requirement.useFieldOr === true,
|
UseFieldOr: requirement.UseFieldOr === true,
|
||||||
Relation: String(requirement.Relation || requirement.relation || "").trim(),
|
Relation: String(requirement.Relation || "").trim(),
|
||||||
UseOr: requirement.UseOr === true || requirement.useOr === true,
|
UseOr: requirement.UseOr === true,
|
||||||
Values: orderedRequirementValues(fields[0] || "", requirement.Values || requirement.values || [])
|
Values: orderedRequirementValues(fields[0] || "", requirement.Values || [])
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return normalized.length ? normalized : [emptyRequirement()];
|
return normalized.length ? normalized : [emptyRequirement()];
|
||||||
@@ -282,14 +264,12 @@
|
|||||||
return criteriaFields.find(function (item) { return item.id === id; });
|
return criteriaFields.find(function (item) { return item.id === id; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRequirementFields(values, legacy) {
|
function normalizeRequirementFields(values) {
|
||||||
var raw = [];
|
var raw = [];
|
||||||
(values || []).forEach(function (value) {
|
(values || []).forEach(function (value) {
|
||||||
value = String(value || "").trim();
|
value = String(value || "").trim();
|
||||||
if (value) raw.push(value);
|
if (value) raw.push(value);
|
||||||
});
|
});
|
||||||
legacy = String(legacy || "").trim();
|
|
||||||
if (legacy) raw.push(legacy);
|
|
||||||
var wanted = {};
|
var wanted = {};
|
||||||
raw.forEach(function (value) {
|
raw.forEach(function (value) {
|
||||||
var id = value.toLowerCase();
|
var id = value.toLowerCase();
|
||||||
@@ -307,7 +287,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requirementFields(requirement) {
|
function requirementFields(requirement) {
|
||||||
return normalizeRequirementFields(requirement && (requirement.Fields || requirement.fields), requirement && (requirement.Field || requirement.field));
|
return normalizeRequirementFields(requirement.Fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
function criteriaLabel(id) {
|
function criteriaLabel(id) {
|
||||||
@@ -317,7 +297,7 @@
|
|||||||
|
|
||||||
function renderSortLocales() {
|
function renderSortLocales() {
|
||||||
var select = $("ml-sort-locale");
|
var select = $("ml-sort-locale");
|
||||||
select.replaceChildren(option("Auto", "Auto", false));
|
M.replaceChildren(select, option("Auto", "Auto", false));
|
||||||
state.allowed.forEach(function (lang) {
|
state.allowed.forEach(function (lang) {
|
||||||
select.appendChild(option(lang, lang, false));
|
select.appendChild(option(lang, lang, false));
|
||||||
});
|
});
|
||||||
@@ -351,7 +331,6 @@
|
|||||||
function syncCategoryFromRow(row, category) {
|
function syncCategoryFromRow(row, category) {
|
||||||
category.Label = row.querySelector("[data-category-label]").value;
|
category.Label = row.querySelector("[data-category-label]").value;
|
||||||
category.MatchAllConditions = row.querySelector("[data-category-match-all]").checked;
|
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"))
|
category.Scopes = Array.prototype.slice.call(row.querySelectorAll("[data-category-scope]:checked"))
|
||||||
.map(function (input) { return input.value; });
|
.map(function (input) { return input.value; });
|
||||||
}
|
}
|
||||||
@@ -364,7 +343,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addSelectOptions(select, items, selectedValue) {
|
function addSelectOptions(select, items, selectedValue) {
|
||||||
select.replaceChildren();
|
M.replaceChildren(select);
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
select.appendChild(option(item.label, item.id, item.id === selectedValue));
|
select.appendChild(option(item.label, item.id, item.id === selectedValue));
|
||||||
});
|
});
|
||||||
@@ -450,23 +429,6 @@
|
|||||||
return values.join(joiner);
|
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) {
|
function makeRequirementRow(category, requirement, index) {
|
||||||
var row = make("div", "ml-requirement-row");
|
var row = make("div", "ml-requirement-row");
|
||||||
var selectedFields = requirementFields(requirement);
|
var selectedFields = requirementFields(requirement);
|
||||||
@@ -512,7 +474,7 @@
|
|||||||
var current = requirement.Relation || "";
|
var current = requirement.Relation || "";
|
||||||
var fields = requirementFields(requirement);
|
var fields = requirementFields(requirement);
|
||||||
var multiple = fields.length > 1;
|
var multiple = fields.length > 1;
|
||||||
select.replaceChildren(option("Choose relation", "", !current));
|
M.replaceChildren(select, option("Choose relation", "", !current));
|
||||||
if (fields.length) {
|
if (fields.length) {
|
||||||
relations.forEach(function (item) {
|
relations.forEach(function (item) {
|
||||||
select.appendChild(option(multiple ? item.pluralLabel : item.label, item.id, item.id === current));
|
select.appendChild(option(multiple ? item.pluralLabel : item.label, item.id, item.id === current));
|
||||||
@@ -557,7 +519,6 @@
|
|||||||
fields = normalizeRequirementFields(fields, "");
|
fields = normalizeRequirementFields(fields, "");
|
||||||
var nextKind = fields.length ? fieldKind(fields[0]) : "";
|
var nextKind = fields.length ? fieldKind(fields[0]) : "";
|
||||||
requirement.Fields = fields;
|
requirement.Fields = fields;
|
||||||
requirement.Field = fields[0] || "";
|
|
||||||
button.textContent = fieldSummary(requirement);
|
button.textContent = fieldSummary(requirement);
|
||||||
refreshFieldPanelOptions(panel, requirement);
|
refreshFieldPanelOptions(panel, requirement);
|
||||||
if (onChange) onChange(previousKind, nextKind);
|
if (onChange) onChange(previousKind, nextKind);
|
||||||
@@ -662,7 +623,6 @@
|
|||||||
function cloneRequirements(requirements) {
|
function cloneRequirements(requirements) {
|
||||||
return normalizeRequirements(requirements).map(function (requirement) {
|
return normalizeRequirements(requirements).map(function (requirement) {
|
||||||
return {
|
return {
|
||||||
Field: requirement.Field,
|
|
||||||
Fields: (requirement.Fields || []).slice(),
|
Fields: (requirement.Fields || []).slice(),
|
||||||
UseFieldOr: requirement.UseFieldOr === true,
|
UseFieldOr: requirement.UseFieldOr === true,
|
||||||
Relation: requirement.Relation,
|
Relation: requirement.Relation,
|
||||||
@@ -676,7 +636,6 @@
|
|||||||
return {
|
return {
|
||||||
Id: randomId(),
|
Id: randomId(),
|
||||||
Label: "",
|
Label: "",
|
||||||
CriteriaText: categoryCriteriaText(category),
|
|
||||||
Requirements: cloneRequirements(category.Requirements),
|
Requirements: cloneRequirements(category.Requirements),
|
||||||
MatchAllConditions: category.MatchAllConditions !== false,
|
MatchAllConditions: category.MatchAllConditions !== false,
|
||||||
Scopes: (category.Scopes || ["M", "S", "C"]).slice(),
|
Scopes: (category.Scopes || ["M", "S", "C"]).slice(),
|
||||||
@@ -686,7 +645,7 @@
|
|||||||
|
|
||||||
function renderCategories() {
|
function renderCategories() {
|
||||||
var root = $("ml-categories");
|
var root = $("ml-categories");
|
||||||
root.replaceChildren();
|
M.replaceChildren(root);
|
||||||
categories().forEach(function (category, index) {
|
categories().forEach(function (category, index) {
|
||||||
var row = make("div", "ml-classification-rule");
|
var row = make("div", "ml-classification-rule");
|
||||||
row.dataset.categoryId = categoryId(category);
|
row.dataset.categoryId = categoryId(category);
|
||||||
@@ -797,7 +756,7 @@
|
|||||||
|
|
||||||
function renderActionSource() {
|
function renderActionSource() {
|
||||||
var root = $("ml-action-source");
|
var root = $("ml-action-source");
|
||||||
root.replaceChildren();
|
M.replaceChildren(root);
|
||||||
actionSourceValues().forEach(function (value) {
|
actionSourceValues().forEach(function (value) {
|
||||||
root.appendChild(makeActionToken(value, false, true));
|
root.appendChild(makeActionToken(value, false, true));
|
||||||
});
|
});
|
||||||
@@ -819,7 +778,7 @@
|
|||||||
function renderActionMatrix() {
|
function renderActionMatrix() {
|
||||||
renderActionSource();
|
renderActionSource();
|
||||||
var root = $("ml-action-matrix");
|
var root = $("ml-action-matrix");
|
||||||
root.replaceChildren();
|
M.replaceChildren(root);
|
||||||
actionRows().forEach(function (row) {
|
actionRows().forEach(function (row) {
|
||||||
var line = make("div", "ml-action-category-row");
|
var line = make("div", "ml-action-category-row");
|
||||||
if (row.fallback) line.classList.add("ml-action-category-row-fallback");
|
if (row.fallback) line.classList.add("ml-action-category-row-fallback");
|
||||||
@@ -1056,7 +1015,7 @@
|
|||||||
|
|
||||||
function clearCategoryActions(categoryId) {
|
function clearCategoryActions(categoryId) {
|
||||||
boxesForCategory(categoryId).forEach(function (box) {
|
boxesForCategory(categoryId).forEach(function (box) {
|
||||||
box.replaceChildren();
|
M.replaceChildren(box);
|
||||||
if (actionFieldLocksJ(box.dataset.fieldId)) box.appendChild(makeActionToken("Jellyfin", true, false));
|
if (actionFieldLocksJ(box.dataset.fieldId)) box.appendChild(makeActionToken("Jellyfin", true, false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1073,7 +1032,7 @@
|
|||||||
return token.dataset.value !== "Jellyfin";
|
return token.dataset.value !== "Jellyfin";
|
||||||
});
|
});
|
||||||
var token = makeActionToken(value, actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin", false);
|
var token = makeActionToken(value, actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin", false);
|
||||||
box.replaceChildren();
|
M.replaceChildren(box);
|
||||||
if (prepend) box.appendChild(token);
|
if (prepend) box.appendChild(token);
|
||||||
existing.forEach(function (item) { box.appendChild(item); });
|
existing.forEach(function (item) { box.appendChild(item); });
|
||||||
if (!prepend) box.appendChild(token);
|
if (!prepend) box.appendChild(token);
|
||||||
@@ -1116,7 +1075,6 @@
|
|||||||
categories().forEach(function (category) {
|
categories().forEach(function (category) {
|
||||||
var complete = (category.Requirements || []).filter(completeRequirement);
|
var complete = (category.Requirements || []).filter(completeRequirement);
|
||||||
category.Requirements = complete.length ? complete : [emptyRequirement()];
|
category.Requirements = complete.length ? complete : [emptyRequirement()];
|
||||||
category.CriteriaText = complete.map(requirementText).join(" and ");
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
Enabled: $("ml-enabled").checked,
|
Enabled: $("ml-enabled").checked,
|
||||||
@@ -1128,12 +1086,10 @@
|
|||||||
return {
|
return {
|
||||||
Id: category.Id,
|
Id: category.Id,
|
||||||
Label: categoryLabel(category, index),
|
Label: categoryLabel(category, index),
|
||||||
CriteriaText: complete.map(requirementText).join(category.MatchAllConditions === false ? " or " : " and "),
|
|
||||||
Requirements: complete,
|
Requirements: complete,
|
||||||
MatchAllConditions: category.MatchAllConditions !== false,
|
MatchAllConditions: category.MatchAllConditions !== false,
|
||||||
Scopes: category.Scopes,
|
Scopes: category.Scopes,
|
||||||
FieldActionLists: category.FieldActionLists,
|
FieldActionLists: category.FieldActionLists
|
||||||
FieldActions: {}
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -1171,7 +1127,7 @@
|
|||||||
$("ml-user-import-modal").hidden = !show;
|
$("ml-user-import-modal").hidden = !show;
|
||||||
if (!show) {
|
if (!show) {
|
||||||
$("ml-user-import-choice").hidden = true;
|
$("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");
|
var select = $("ml-user-import-source");
|
||||||
select.replaceChildren();
|
M.replaceChildren(select);
|
||||||
ids.forEach(function (id) { select.appendChild(option(id, id, false)); });
|
ids.forEach(function (id) { select.appendChild(option(id, id, false)); });
|
||||||
choice.hidden = false;
|
choice.hidden = false;
|
||||||
M.setStatus(status, "No matching user ID was found. Choose which exported user settings to import.", true);
|
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-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 () {
|
$("ml-user-export").addEventListener("click", function () {
|
||||||
downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); });
|
downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); });
|
||||||
});
|
});
|
||||||
@@ -1272,26 +1239,26 @@
|
|||||||
$("ml-user-import-run").addEventListener("click", function () {
|
$("ml-user-import-run").addEventListener("click", function () {
|
||||||
runUserImport().catch(function (err) { M.setStatus(status, String(err), true); });
|
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 () {
|
$("ml-add-category").addEventListener("click", function () {
|
||||||
syncCategoriesFromDom();
|
syncCategoriesFromDom();
|
||||||
updateActionStateFromBoxes();
|
updateActionStateFromBoxes();
|
||||||
categories().push(newCategory("", ""));
|
categories().push(newCategory(""));
|
||||||
renderAll();
|
renderAll();
|
||||||
});
|
});
|
||||||
document.addEventListener("dragstart", function (event) {
|
listen(document, "dragstart", function (event) {
|
||||||
if (event.target.closest && event.target.closest(".ml-action-token")) event.preventDefault();
|
if (event.target.closest && event.target.closest(".ml-action-token")) event.preventDefault();
|
||||||
}, true);
|
}, true);
|
||||||
page.addEventListener("pointerdown", beginActionDrag, true);
|
page.addEventListener("pointerdown", beginActionDrag, true);
|
||||||
document.addEventListener("pointermove", moveActionDrag, true);
|
listen(document, "pointermove", moveActionDrag, true);
|
||||||
document.addEventListener("pointerup", endActionDrag, true);
|
listen(document, "pointerup", endActionDrag, true);
|
||||||
document.addEventListener("pointercancel", function (event) {
|
listen(document, "pointercancel", function (event) {
|
||||||
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
|
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
|
||||||
restoreActionDrag();
|
restoreActionDrag();
|
||||||
cleanupActionDrag();
|
cleanupActionDrag();
|
||||||
updateActionStateFromBoxes();
|
updateActionStateFromBoxes();
|
||||||
}, true);
|
}, true);
|
||||||
document.addEventListener("viewshow", function (event) {
|
listen(document, "viewshow", function (event) {
|
||||||
if (event.target && event.target.id === "ml-user") load();
|
if (event.target && event.target.id === "ml-user") load();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ public sealed partial class TranslationStore
|
|||||||
delete.CommandText = @"
|
delete.CommandText = @"
|
||||||
DELETE FROM facts WHERE item_id = $item_id;
|
DELETE FROM facts WHERE item_id = $item_id;
|
||||||
DELETE FROM translations 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.Parameters.AddWithValue("$item_id", itemId);
|
||||||
delete.ExecuteNonQuery();
|
delete.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit();
|
tx.Commit();
|
||||||
CleanupUnreferencedAssetFiles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalCleanupResult CleanupForConfiguration(IReadOnlySet<string> liveItemIds, IEnumerable<string> allowedLanguages, bool localAssetStorage)
|
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)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.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 translationsDeleted = DeleteRowsOutsideLanguages("translations", allowed);
|
||||||
var genresDeleted = DeleteRowsOutsideLanguages("genres", allowed);
|
var genresDeleted = DeleteRowsOutsideLanguages("genres", allowed);
|
||||||
var assetsDeleted = DeleteRowsOutsideLanguages("assets", allowed);
|
var assetsDeleted = DeleteRowsOutsideLanguages("assets", allowed);
|
||||||
var modeRowsDeleted = localAssetStorage ? 0 : DeleteLocalAssetRows(resetMissingCheckedAt: true);
|
var modeRowsDeleted = localAssetStorage ? 0 : SwitchLocalAssetsToUrls();
|
||||||
var assetFilesDeleted = CleanupUnreferencedAssetFiles();
|
var assetFilesDeleted = CleanupUnreferencedAssetFiles();
|
||||||
return new LocalCleanupResult(translationsDeleted, assetsDeleted + modeRowsDeleted, genresDeleted, assetFilesDeleted);
|
return new LocalCleanupResult(translationsDeleted, assetsDeleted + modeRowsDeleted, genresDeleted, assetFilesDeleted);
|
||||||
}
|
}
|
||||||
@@ -80,38 +88,22 @@ DELETE FROM assets WHERE item_id = $item_id;";
|
|||||||
return cmd.ExecuteNonQuery();
|
return cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int DeleteLocalAssetRows(bool resetMissingCheckedAt)
|
private int SwitchLocalAssetsToUrls()
|
||||||
{
|
{
|
||||||
using var con = Open();
|
using var con = Open();
|
||||||
con.Open();
|
con.Open();
|
||||||
using var tx = con.BeginTransaction();
|
using var tx = con.BeginTransaction();
|
||||||
var affected = new List<string>();
|
using var cmd = con.CreateCommand();
|
||||||
using (var select = con.CreateCommand())
|
cmd.Transaction = tx;
|
||||||
{
|
cmd.CommandText = @"UPDATE assets SET path = source_url, path_low = lower(source_url)
|
||||||
select.Transaction = tx;
|
WHERE path_low LIKE '/multilang/assets/%' AND source_url <> '';
|
||||||
select.CommandText = "SELECT DISTINCT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%';";
|
DELETE FROM fetch_state WHERE source LIKE 'artwork:%' AND item_id IN
|
||||||
using var reader = select.ExecuteReader();
|
(SELECT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%');
|
||||||
while (reader.Read())
|
UPDATE facts SET missing_checked_at = 0 WHERE item_id IN
|
||||||
affected.Add(reader.GetString(0));
|
(SELECT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%');";
|
||||||
}
|
cmd.ExecuteNonQuery();
|
||||||
|
cmd.CommandText = "DELETE FROM assets WHERE path_low LIKE '/multilang/assets/%';";
|
||||||
int deleted;
|
var deleted = cmd.ExecuteNonQuery();
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.Commit();
|
tx.Commit();
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
@@ -163,13 +155,13 @@ DELETE FROM assets WHERE item_id = $item_id;";
|
|||||||
public bool TryNormalizeLocalAssetPath(string path, out string fullPath)
|
public bool TryNormalizeLocalAssetPath(string path, out string fullPath)
|
||||||
{
|
{
|
||||||
fullPath = string.Empty;
|
fullPath = string.Empty;
|
||||||
if (string.IsNullOrWhiteSpace(path) || Uri.TryCreate(path, UriKind.Absolute, out _))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var relative = path;
|
var relative = path;
|
||||||
if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase))
|
if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
relative = relative[LocalAssetUrlPrefix.Length..];
|
relative = relative[LocalAssetUrlPrefix.Length..];
|
||||||
else if (Path.IsPathRooted(relative))
|
else if (Uri.TryCreate(relative, UriKind.Absolute, out _) || Path.IsPathRooted(relative))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative));
|
var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative));
|
||||||
|
|||||||
@@ -15,21 +15,34 @@ public sealed partial class TranslationStore
|
|||||||
destination.Open();
|
destination.Open();
|
||||||
using (var schema = destination.CreateCommand())
|
using (var schema = destination.CreateCommand())
|
||||||
{
|
{
|
||||||
schema.CommandText = SchemaSql;
|
schema.CommandText = SchemaSql + "\nPRAGMA user_version = 1;";
|
||||||
schema.ExecuteNonQuery();
|
schema.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
using var source = Open();
|
using var source = Open();
|
||||||
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");
|
using var snapshot = source.BeginTransaction();
|
||||||
CopyTable(source, destination, "translations", "item_id, lang, field, text");
|
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(source, destination, "assets", "item_id, lang, kind, path, path_low, updated_at");
|
CopyTable(snapshot, destination, "translations", "item_id, lang, field, text");
|
||||||
CopyTable(source, destination, "genres", "tmdb_id, media, lang, name, name_norm");
|
CopyTable(snapshot, destination, "assets", "item_id, lang, kind, path, path_low, updated_at, source_url, provider");
|
||||||
CopyTable(source, destination, "scan_state", "id, last_scan_started");
|
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)
|
public ImportDatabaseResult ImportTranslationDatabase(string sourcePath, IReadOnlySet<string> liveItemIds, IReadOnlySet<string> allowedLanguages)
|
||||||
{
|
{
|
||||||
|
ValidateTranslationBackup(sourcePath);
|
||||||
var allowed = allowedLanguages.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var allowed = allowedLanguages.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
allowed.Add(MultilangConstants.OriginalAction);
|
allowed.Add(MultilangConstants.OriginalAction);
|
||||||
|
|
||||||
@@ -39,187 +52,62 @@ public sealed partial class TranslationStore
|
|||||||
source.Open();
|
source.Open();
|
||||||
using var tx = destination.BeginTransaction();
|
using var tx = destination.BeginTransaction();
|
||||||
|
|
||||||
var facts = ImportFacts(source, destination, tx, liveItemIds);
|
bool Live(SqliteDataReader row) => liveItemIds.Contains(row.GetString(0));
|
||||||
var translations = ImportTranslations(source, destination, tx, liveItemIds, allowed);
|
bool Translation(SqliteDataReader row) => Live(row) && allowed.Contains(row.GetString(1));
|
||||||
var assets = ImportAssets(source, destination, tx, liveItemIds, allowed);
|
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 genres = ImportGenres(source, destination, tx, allowed);
|
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();
|
tx.Commit();
|
||||||
return new ImportDatabaseResult(facts, translations, assets, genres);
|
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};";
|
read.CommandText = $"SELECT {columns} FROM {table};";
|
||||||
using var reader = read.ExecuteReader();
|
using var reader = read.ExecuteReader();
|
||||||
using var tx = destination.BeginTransaction();
|
using var tx = destination.BeginTransaction();
|
||||||
while (reader.Read())
|
var names = columns.Split(',', StringSplitOptions.TrimEntries);
|
||||||
{
|
|
||||||
using var insert = destination.CreateCommand();
|
using var insert = destination.CreateCommand();
|
||||||
insert.Transaction = tx;
|
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)))});";
|
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++)
|
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();
|
insert.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit();
|
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();
|
using var read = source.CreateCommand();
|
||||||
read.CommandText = @"
|
read.CommandText = $"SELECT {columns} FROM {table};";
|
||||||
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;";
|
|
||||||
using var reader = read.ExecuteReader();
|
using var reader = read.ExecuteReader();
|
||||||
var count = 0;
|
using var insert = tx.Connection!.CreateCommand();
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
var itemId = reader.GetString(0);
|
|
||||||
if (!liveItemIds.Contains(itemId))
|
|
||||||
continue;
|
|
||||||
using var insert = destination.CreateCommand();
|
|
||||||
insert.Transaction = tx;
|
insert.Transaction = tx;
|
||||||
insert.CommandText = @"
|
var names = columns.Split(',', StringSplitOptions.TrimEntries);
|
||||||
INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all,
|
insert.CommandText = $"INSERT OR REPLACE INTO {table}({columns}) VALUES({string.Join(",", names.Select((_, i) => "$v" + i))});";
|
||||||
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"
|
|
||||||
};
|
|
||||||
for (var i = 0; i < names.Length; i++)
|
for (var i = 0; i < names.Length; i++)
|
||||||
insert.Parameters.AddWithValue(names[i], reader.GetValue(i));
|
insert.Parameters.Add(new SqliteParameter("$v" + i, DBNull.Value));
|
||||||
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();
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
var itemId = reader.GetString(0);
|
if (!include(reader))
|
||||||
var lang = reader.GetString(1);
|
|
||||||
if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang))
|
|
||||||
continue;
|
continue;
|
||||||
using var insert = destination.CreateCommand();
|
for (var i = 0; i < names.Length; i++)
|
||||||
insert.Transaction = tx;
|
insert.Parameters[i].Value = reader.GetValue(i);
|
||||||
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));
|
|
||||||
insert.ExecuteNonQuery();
|
insert.ExecuteNonQuery();
|
||||||
count++;
|
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;
|
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 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 LocalCleanupResult(int TranslationsDeleted, int AssetsDeleted, int GenresDeleted, int AssetFilesDeleted);
|
||||||
|
|
||||||
public readonly record struct ImportDatabaseResult(int Facts, int Translations, int Assets, int Genres);
|
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 Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string CriteriaText { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public UserCategoryRequirement[] Requirements { get; set; } = [];
|
public UserCategoryRequirement[] Requirements { get; set; } = [];
|
||||||
|
|
||||||
public bool MatchAllConditions { get; set; } = true;
|
public bool MatchAllConditions { get; set; } = true;
|
||||||
|
|
||||||
public string[] Scopes { get; set; } = ["M", "S", "C"];
|
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 Dictionary<string, string[]> FieldActionLists { get; set; } = MultilangConstants.DefaultActionLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UserCategoryRequirement
|
public sealed class UserCategoryRequirement
|
||||||
{
|
{
|
||||||
public string Field { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public string[] Fields { get; set; } = [];
|
public string[] Fields { get; set; } = [];
|
||||||
|
|
||||||
public bool UseFieldOr { 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,
|
kind TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
path_low TEXT NOT NULL DEFAULT '',
|
path_low TEXT NOT NULL DEFAULT '',
|
||||||
|
source_url TEXT NOT NULL DEFAULT '',
|
||||||
|
provider TEXT NOT NULL DEFAULT '',
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (item_id, lang, kind)
|
PRIMARY KEY (item_id, lang, kind)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_item ON assets(item_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS genres (
|
||||||
tmdb_id INTEGER NOT NULL,
|
tmdb_id INTEGER NOT NULL,
|
||||||
media TEXT NOT NULL,
|
media TEXT NOT NULL,
|
||||||
|
|||||||
@@ -15,6 +15,34 @@ public sealed partial class TranslationStore
|
|||||||
private readonly string _dataDir;
|
private readonly string _dataDir;
|
||||||
private readonly string _assetsDir;
|
private readonly string _assetsDir;
|
||||||
private readonly string _dbPath;
|
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)
|
public TranslationStore(IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
@@ -24,6 +52,8 @@ public sealed partial class TranslationStore
|
|||||||
Directory.CreateDirectory(_assetsDir);
|
Directory.CreateDirectory(_assetsDir);
|
||||||
_dbPath = Path.Combine(_dataDir, "translations.sqlite");
|
_dbPath = Path.Combine(_dataDir, "translations.sqlite");
|
||||||
Initialize();
|
Initialize();
|
||||||
|
if (Plugin.Instance is { } plugin)
|
||||||
|
plugin.Store = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DataDirectory => _dataDir;
|
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());
|
=> JsonSerializer.Serialize(values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||||
|
|
||||||
public SqliteConnection Open()
|
public SqliteConnection Open()
|
||||||
=> new($"Data Source={_dbPath};Cache=Shared;Pooling=False");
|
=> new($"Data Source={_dbPath};Pooling=False");
|
||||||
|
|
||||||
private void Initialize()
|
private void Initialize()
|
||||||
{
|
{
|
||||||
using var con = Open();
|
using var con = Open();
|
||||||
con.Open();
|
con.Open();
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = con.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA journal_mode=WAL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
cmd.CommandText = SchemaSql;
|
cmd.CommandText = SchemaSql;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
EnsureColumn(con, "assets", "path_low", "TEXT NOT NULL DEFAULT ''");
|
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()
|
public void ResetAll()
|
||||||
{
|
{
|
||||||
SqliteConnection.ClearAllPools();
|
using var con = Open();
|
||||||
if (Directory.Exists(_dataDir))
|
con.Open();
|
||||||
Directory.Delete(_dataDir, recursive: true);
|
using var tx = con.BeginTransaction();
|
||||||
Directory.CreateDirectory(_dataDir);
|
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);
|
Directory.CreateDirectory(_assetsDir);
|
||||||
Initialize();
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public (long DatabaseBytes, long AssetsBytes) GetStorageUsage()
|
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;
|
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();
|
Write(transaction, tx =>
|
||||||
con.Open();
|
{
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = tx.Connection!.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO facts(
|
INSERT INTO facts(
|
||||||
item_id, tmdb_id, kind, original_title, original_language, original_language_all,
|
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("$missing_checked_at", missingCheckedAt);
|
||||||
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
|
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
|
||||||
cmd.ExecuteNonQuery();
|
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();
|
Write(transaction, tx =>
|
||||||
con.Open();
|
{
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = tx.Connection!.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO facts(
|
INSERT INTO facts(
|
||||||
item_id, tmdb_id, kind, original_title, original_language, original_language_all,
|
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("$missing_checked_at", missingCheckedAt);
|
||||||
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
|
cmd.Parameters.AddWithValue("$full_checked_at", fullCheckedAt);
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public FactsData? GetFacts(string itemId)
|
public FactsData? GetFacts(string itemId)
|
||||||
@@ -290,11 +335,12 @@ WHERE item_id IN ({idParams});";
|
|||||||
return rows.ToArray();
|
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();
|
Write(transaction, tx =>
|
||||||
con.Open();
|
{
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = tx.Connection!.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO translations(item_id, lang, field, text)
|
INSERT INTO translations(item_id, lang, field, text)
|
||||||
VALUES($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("$field", field);
|
||||||
cmd.Parameters.AddWithValue("$text", text);
|
cmd.Parameters.AddWithValue("$text", text);
|
||||||
cmd.ExecuteNonQuery();
|
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();
|
Write(transaction, tx =>
|
||||||
con.Open();
|
{
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = tx.Connection!.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO assets(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)
|
VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at, $source_url)
|
||||||
ON CONFLICT(item_id, lang, kind) DO UPDATE SET
|
ON CONFLICT(item_id, lang, kind) DO UPDATE SET
|
||||||
path = excluded.path,
|
path = excluded.path,
|
||||||
path_low = excluded.path_low,
|
path_low = excluded.path_low,
|
||||||
|
source_url = excluded.source_url,
|
||||||
updated_at = excluded.updated_at;";
|
updated_at = excluded.updated_at;";
|
||||||
cmd.Parameters.AddWithValue("$item_id", itemId);
|
cmd.Parameters.AddWithValue("$item_id", itemId);
|
||||||
cmd.Parameters.AddWithValue("$lang", lang);
|
cmd.Parameters.AddWithValue("$lang", lang);
|
||||||
cmd.Parameters.AddWithValue("$kind", kind);
|
cmd.Parameters.AddWithValue("$kind", kind);
|
||||||
cmd.Parameters.AddWithValue("$path", path);
|
cmd.Parameters.AddWithValue("$path", path);
|
||||||
cmd.Parameters.AddWithValue("$path_low", path.ToLowerInvariant());
|
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.Parameters.AddWithValue("$updated_at", updatedAt);
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetAssetPath(string itemId, string lang, string kind)
|
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;
|
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();
|
using var con = Open();
|
||||||
con.Open();
|
con.Open();
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = con.CreateCommand();
|
||||||
var langParams = AddParams(cmd, langs, "$lang");
|
cmd.CommandText = "SELECT 1 FROM assets WHERE item_id = $item AND path = $path LIMIT 1;";
|
||||||
cmd.CommandText = $"SELECT path_low FROM assets WHERE item_id = $item_id AND lang IN ({langParams});";
|
cmd.Parameters.AddWithValue("$item", itemId);
|
||||||
cmd.Parameters.AddWithValue("$item_id", itemId);
|
cmd.Parameters.AddWithValue("$path", path);
|
||||||
using var reader = cmd.ExecuteReader();
|
return cmd.ExecuteScalar() is not null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AssetPresence(any, anyRemote);
|
public void UpsertGenre(int tmdbId, string media, string lang, string name, SqliteTransaction? transaction = null)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name))
|
if (tmdbId <= 0 || string.IsNullOrWhiteSpace(media) || string.IsNullOrWhiteSpace(lang) || string.IsNullOrWhiteSpace(name))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
using var con = Open();
|
Write(transaction, tx =>
|
||||||
con.Open();
|
{
|
||||||
using var cmd = con.CreateCommand();
|
using var cmd = tx.Connection!.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
cmd.CommandText = @"
|
cmd.CommandText = @"
|
||||||
INSERT INTO genres(tmdb_id, media, lang, name, name_norm)
|
INSERT INTO genres(tmdb_id, media, lang, name, name_norm)
|
||||||
VALUES($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", name.Trim());
|
||||||
cmd.Parameters.AddWithValue("$name_norm", name.Trim().ToLowerInvariant());
|
cmd.Parameters.AddWithValue("$name_norm", name.Trim().ToLowerInvariant());
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public GenreRow[] GetGenres(string media)
|
public GenreRow[] GetGenres(string media)
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>Jellyfin.Plugin.Multilang</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.Multilang</RootNamespace>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<Version>0.2.0</Version>
|
<Version>0.3.4</Version>
|
||||||
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
<AssemblyVersion>0.3.4.0</AssemblyVersion>
|
||||||
<FileVersion>0.2.0.0</FileVersion>
|
<FileVersion>0.3.4.0</FileVersion>
|
||||||
<Authors>ajp_anton</Authors>
|
<Authors>ajp_anton</Authors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -17,13 +18,14 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.9">
|
<PackageReference Include="Jellyfin.Controller" Version="12.0.0">
|
||||||
<ExcludeAssets>runtime</ExcludeAssets>
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Jellyfin.Model" Version="10.11.9">
|
<PackageReference Include="Jellyfin.Model" Version="12.0.0">
|
||||||
<ExcludeAssets>runtime</ExcludeAssets>
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
</PackageReference>
|
</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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ namespace Jellyfin.Plugin.Multilang;
|
|||||||
|
|
||||||
internal static class MultilangConstants
|
internal static class MultilangConstants
|
||||||
{
|
{
|
||||||
|
public const int RefreshConcurrency = 4;
|
||||||
public const string JellyfinAction = "Jellyfin";
|
public const string JellyfinAction = "Jellyfin";
|
||||||
public const string OriginalAction = "Original";
|
public const string OriginalAction = "Original";
|
||||||
public const string FallbackAction = "Fallback";
|
public const string FallbackAction = "Fallback";
|
||||||
@@ -25,12 +26,6 @@ internal static class MultilangConstants
|
|||||||
=> field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) ||
|
=> field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) ||
|
||||||
field.Equals(PosterKind, 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()
|
public static Dictionary<string, string[]> DefaultActionLists()
|
||||||
=> ActionFields.ToDictionary(
|
=> ActionFields.ToDictionary(
|
||||||
field => field,
|
field => field,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace Jellyfin.Plugin.Multilang;
|
|||||||
|
|
||||||
public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
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 static Plugin? Instance { get; private set; }
|
||||||
|
|
||||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||||
@@ -24,6 +26,8 @@ public sealed class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
public override void OnUninstalling()
|
public override void OnUninstalling()
|
||||||
{
|
{
|
||||||
|
Uninstalling = true;
|
||||||
|
using var maintenance = Store?.EnterMaintenanceAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
WebScriptInjector.RemoveInjected(ApplicationPaths.WebPath);
|
WebScriptInjector.RemoveInjected(ApplicationPaths.WebPath);
|
||||||
|
|
||||||
if (Configuration.CleanupDataOnUninstall)
|
if (Configuration.CleanupDataOnUninstall)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
|
public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
|
||||||
{
|
{
|
||||||
services.AddMvc().AddApplicationPart(typeof(Plugin).Assembly);
|
services.AddMvc().AddApplicationPart(typeof(Plugin).Assembly);
|
||||||
|
services.AddHttpClient("Multilang.Jellyfin")
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||||
|
|
||||||
services.AddSingleton<TranslationStore>();
|
services.AddSingleton<TranslationStore>();
|
||||||
services.AddSingleton<ProviderCatalog>();
|
services.AddSingleton<ProviderCatalog>();
|
||||||
@@ -25,7 +27,11 @@ public sealed class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
services.AddSingleton<FanartClient>();
|
services.AddSingleton<FanartClient>();
|
||||||
services.AddSingleton<RefreshCoordinator>();
|
services.AddSingleton<RefreshCoordinator>();
|
||||||
services.AddSingleton<RefreshService>();
|
services.AddSingleton<RefreshService>();
|
||||||
|
services.AddSingleton<ItemFetcher>();
|
||||||
services.AddSingleton<ItemsProxyCache>();
|
services.AddSingleton<ItemsProxyCache>();
|
||||||
|
services.AddHostedService<ItemsProxyInvalidationService>();
|
||||||
|
services.AddSingleton<ItemsProxyRequestCoalescer>();
|
||||||
|
services.AddSingleton<ItemsProxyPrecacheService>();
|
||||||
services.AddSingleton<ItemsProxyTransformer>();
|
services.AddSingleton<ItemsProxyTransformer>();
|
||||||
services.AddSingleton<AssetStorageService>();
|
services.AddSingleton<AssetStorageService>();
|
||||||
services.AddSingleton<MultilangBackupService>();
|
services.AddSingleton<MultilangBackupService>();
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ public sealed record CategoryRuleTrace(
|
|||||||
string[] Scopes,
|
string[] Scopes,
|
||||||
bool ScopeMatches,
|
bool ScopeMatches,
|
||||||
bool MatchAllConditions,
|
bool MatchAllConditions,
|
||||||
bool UsesStructuredRequirements,
|
|
||||||
bool Result,
|
bool Result,
|
||||||
RequirementTrace[] Requirements,
|
RequirementTrace[] Requirements);
|
||||||
string CriteriaText);
|
|
||||||
|
|
||||||
public sealed record RequirementTrace(
|
public sealed record RequirementTrace(
|
||||||
string[] Fields,
|
string[] Fields,
|
||||||
@@ -33,59 +31,40 @@ public static class CategoryRuleEvaluator
|
|||||||
{
|
{
|
||||||
public static bool Matches(UserCategoryRule category, FactsData facts)
|
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)
|
public static CategoryRuleTrace Trace(UserCategoryRule category, FactsData facts)
|
||||||
{
|
{
|
||||||
var scopeMatches = ScopeMatches(category, facts.Kind);
|
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 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.All(requirement => requirement.Result)
|
||||||
: requirements.Any(requirement => requirement.Result);
|
: requirements.Any(requirement => requirement.Result));
|
||||||
return new CategoryRuleTrace(
|
return new(category.Id, category.Label, facts.Kind, category.Scopes,
|
||||||
category.Id,
|
scopeMatches, category.MatchAllConditions, result, requirements);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string[] ParseJsonStringArray(string json)
|
public static string[] ParseJsonStringArray(string json)
|
||||||
@@ -120,42 +99,6 @@ public static class CategoryRuleEvaluator
|
|||||||
return scopes.Contains(wanted, StringComparer.OrdinalIgnoreCase);
|
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)
|
private static RequirementTrace TraceRequirement(UserCategoryRequirement requirement, FactsData facts)
|
||||||
{
|
{
|
||||||
var fields = RequirementFields(requirement);
|
var fields = RequirementFields(requirement);
|
||||||
@@ -201,7 +144,11 @@ public static class CategoryRuleEvaluator
|
|||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.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" => RequirementIs(actual, expected, useOr),
|
||||||
"is_not" => !RequirementIs(actual, expected, useOr),
|
"is_not" => !RequirementIs(actual, expected, useOr),
|
||||||
@@ -209,23 +156,9 @@ public static class CategoryRuleEvaluator
|
|||||||
"not_contains" => !RequirementContains(actual, expected, useOr),
|
"not_contains" => !RequirementContains(actual, expected, useOr),
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
return new RequirementFieldTrace(field, actual, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string[] RequirementFields(UserCategoryRequirement requirement)
|
private static string[] RequirementFields(UserCategoryRequirement requirement)
|
||||||
{
|
=> NormalizeValues(requirement.Fields);
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool RequirementIs(string[] actual, string[] expected, bool useOr)
|
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)
|
private static bool EqualsValue(string actual, string expected)
|
||||||
=> string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
|
=> 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)
|
private static string[] FieldValues(string field, FactsData facts)
|
||||||
{
|
{
|
||||||
field = field.Trim().ToLowerInvariant();
|
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 System.Security.Cryptography;
|
||||||
using Jellyfin.Plugin.Multilang.Data;
|
using Jellyfin.Plugin.Multilang.Data;
|
||||||
|
|
||||||
@@ -18,8 +17,6 @@ public sealed class AssetStorageService
|
|||||||
|
|
||||||
public async Task<string?> StoreAsync(
|
public async Task<string?> StoreAsync(
|
||||||
string itemId,
|
string itemId,
|
||||||
string lang,
|
|
||||||
string kind,
|
|
||||||
string sourceUrl,
|
string sourceUrl,
|
||||||
bool localStorage,
|
bool localStorage,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -30,22 +27,17 @@ public sealed class AssetStorageService
|
|||||||
if (!localStorage)
|
if (!localStorage)
|
||||||
return sourceUrl;
|
return sourceUrl;
|
||||||
|
|
||||||
var relative = BuildRelativePath(itemId, lang, kind, sourceUrl);
|
var relative = BuildRelativePath(itemId, sourceUrl);
|
||||||
var expectedUrl = TranslationStore.LocalAssetUrlPrefix + relative.Replace('\\', '/');
|
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 destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative));
|
||||||
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
|
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
|
||||||
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||||
throw new InvalidOperationException("Resolved asset path escaped the Multilang assets directory.");
|
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);
|
await DownloadAsync(sourceUrl, destination, cancellationToken).ConfigureAwait(false);
|
||||||
return expectedUrl;
|
return expectedUrl;
|
||||||
}
|
}
|
||||||
@@ -101,11 +93,11 @@ public sealed class AssetStorageService
|
|||||||
File.Move(temp, destination, overwrite: true);
|
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 extension = ExtensionFromUrl(sourceUrl);
|
||||||
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sourceUrl))).ToLowerInvariant()[..16];
|
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)
|
private static string SafeSegment(string value)
|
||||||
@@ -133,6 +125,6 @@ public sealed class AssetStorageService
|
|||||||
".png" => "image/png",
|
".png" => "image/png",
|
||||||
".webp" => "image/webp",
|
".webp" => "image/webp",
|
||||||
".gif" => "image/gif",
|
".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.Globalization;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -142,6 +143,14 @@ public sealed class MultilangBackupService
|
|||||||
|
|
||||||
using var zip = ZipFile.OpenRead(zipPath);
|
using var zip = ZipFile.OpenRead(zipPath);
|
||||||
ValidateManifest(zip);
|
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 importedConfig = false;
|
||||||
var importedUsers = 0;
|
var importedUsers = 0;
|
||||||
var ignoredUsers = 0;
|
var ignoredUsers = 0;
|
||||||
@@ -173,21 +182,13 @@ public sealed class MultilangBackupService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_store.SaveUserRules(userId, payload.Rules);
|
_store.SaveUserRules(userId, UserRulesNormalizer.Normalize(payload.Rules, currentConfiguration.Languages));
|
||||||
importedUsers++;
|
importedUsers++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.TranslationsDatabase)
|
|
||||||
{
|
|
||||||
var dbEntry = zip.GetEntry(DatabaseName);
|
|
||||||
if (dbEntry is not null)
|
if (dbEntry is not null)
|
||||||
{
|
|
||||||
var dbPath = Path.Combine(tempDir, DatabaseName);
|
|
||||||
dbEntry.ExtractToFile(dbPath, overwrite: true);
|
|
||||||
database = _store.ImportTranslationDatabase(dbPath, GetLiveItemIds(), GetAllowedLanguages(currentConfiguration));
|
database = _store.ImportTranslationDatabase(dbPath, GetLiveItemIds(), GetAllowedLanguages(currentConfiguration));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.DownloadedAssets)
|
if (options.DownloadedAssets)
|
||||||
{
|
{
|
||||||
@@ -268,7 +269,7 @@ public sealed class MultilangBackupService
|
|||||||
if (payload?.Rules is null)
|
if (payload?.Rules is null)
|
||||||
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, [], false);
|
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);
|
return new BackupImportResult(false, 1, 0, 0, 0, 0, 0, 0, 0, [], false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public sealed class ItemsProxyCache
|
|||||||
|
|
||||||
public required string Url { get; init; }
|
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; }
|
public required string Body { get; init; }
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ public sealed class ItemsProxyCache
|
|||||||
public required long DurationMs { get; init; }
|
public required long DurationMs { get; init; }
|
||||||
|
|
||||||
public required long SizeBytes { get; init; }
|
public required long SizeBytes { get; init; }
|
||||||
|
public bool Micro { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RecentRequest
|
private sealed class RecentRequest
|
||||||
@@ -33,6 +34,8 @@ public sealed class ItemsProxyCache
|
|||||||
|
|
||||||
public required bool CacheStored { get; init; }
|
public required bool CacheStored { get; init; }
|
||||||
|
|
||||||
|
public required bool InFlightCoalesced { get; init; }
|
||||||
|
|
||||||
public required int StatusCode { get; init; }
|
public required int StatusCode { get; init; }
|
||||||
|
|
||||||
public required int ItemCount { get; init; }
|
public required int ItemCount { get; init; }
|
||||||
@@ -48,50 +51,44 @@ public sealed class ItemsProxyCache
|
|||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
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 readonly Queue<RecentRequest> _recentRequests = new();
|
||||||
private long _totalBytes;
|
private long _totalBytes;
|
||||||
|
private long _generation;
|
||||||
|
public long Generation { get { lock (_lock) return _generation; } }
|
||||||
private const int MaxRecentRequests = 100;
|
private const int MaxRecentRequests = 100;
|
||||||
private const int MaxMicroEntries = 50;
|
private const int MaxMicroEntries = 50;
|
||||||
private static readonly TimeSpan MicroTtl = TimeSpan.FromSeconds(3);
|
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()
|
public void ClearAll()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_entries.Clear();
|
_entries.Clear();
|
||||||
_microEntries.Clear();
|
|
||||||
_totalBytes = 0;
|
_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)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
foreach (var key in _entries.Values
|
_generation++;
|
||||||
.Where(e => e.ItemIds.Overlaps(ids))
|
foreach (var key in _entries.Values.Where(e => e.UserId == userId).Select(e => e.Key).ToArray())
|
||||||
.Select(e => e.Key)
|
|
||||||
.ToArray())
|
|
||||||
{
|
|
||||||
Remove(key);
|
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)
|
public bool TryGet(string key, TimeSpan ttl, out CachedItemsProxyResponse response)
|
||||||
@@ -100,24 +97,17 @@ public sealed class ItemsProxyCache
|
|||||||
{
|
{
|
||||||
PurgeExpired(ttl);
|
PurgeExpired(ttl);
|
||||||
if (!_entries.TryGetValue(key, out var entry))
|
if (!_entries.TryGetValue(key, out var entry))
|
||||||
{
|
|
||||||
PurgeExpired(_microEntries, MicroTtl);
|
|
||||||
if (!_microEntries.TryGetValue(key, out var microEntry))
|
|
||||||
{
|
{
|
||||||
response = default;
|
response = default;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
response = new CachedItemsProxyResponse(microEntry.Body, microEntry.ContentType, microEntry.ItemIds.Count, microEntry.SizeBytes);
|
response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemCount, entry.SizeBytes);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemIds.Count, entry.SizeBytes);
|
|
||||||
return true;
|
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)
|
if (maxBytes <= 0 || ttl <= TimeSpan.Zero)
|
||||||
return false;
|
return false;
|
||||||
@@ -128,76 +118,40 @@ public sealed class ItemsProxyCache
|
|||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
|
if (generation.HasValue && generation != _generation)
|
||||||
|
return false;
|
||||||
PurgeExpired(ttl);
|
PurgeExpired(ttl);
|
||||||
Remove(key);
|
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)
|
while (_totalBytes + sizeBytes > maxBytes && _entries.Count > 0)
|
||||||
{
|
{
|
||||||
var victim = _entries.Values.OrderBy(e => e.CreatedUtc).First();
|
var victim = _entries.Values.OrderBy(e => e.CreatedUtc).First();
|
||||||
Remove(victim.Key);
|
Remove(victim.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_totalBytes + sizeBytes > maxBytes)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
_entries[key] = new Entry
|
_entries[key] = new Entry
|
||||||
{
|
{
|
||||||
Key = key,
|
Key = key,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Url = url,
|
Url = url,
|
||||||
ItemIds = itemIds
|
ItemCount = itemCount,
|
||||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
|
||||||
.Select(id => id.Trim().ToLowerInvariant())
|
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase),
|
|
||||||
Body = body,
|
Body = body,
|
||||||
ContentType = contentType,
|
ContentType = contentType,
|
||||||
CreatedUtc = DateTimeOffset.UtcNow,
|
CreatedUtc = DateTimeOffset.UtcNow,
|
||||||
DurationMs = durationMs,
|
DurationMs = durationMs,
|
||||||
SizeBytes = sizeBytes
|
SizeBytes = sizeBytes,
|
||||||
|
Micro = micro
|
||||||
};
|
};
|
||||||
_totalBytes += sizeBytes;
|
_totalBytes += sizeBytes;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool StoreMicro(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes)
|
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);
|
||||||
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 object[] GetEntries()
|
public object[] GetEntries()
|
||||||
{
|
{
|
||||||
@@ -205,6 +159,7 @@ public sealed class ItemsProxyCache
|
|||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
return _entries.Values
|
return _entries.Values
|
||||||
|
.Where(e => !e.Micro)
|
||||||
.OrderByDescending(e => e.CreatedUtc)
|
.OrderByDescending(e => e.CreatedUtc)
|
||||||
.Select(e => new
|
.Select(e => new
|
||||||
{
|
{
|
||||||
@@ -213,7 +168,7 @@ public sealed class ItemsProxyCache
|
|||||||
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
|
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
|
||||||
e.DurationMs,
|
e.DurationMs,
|
||||||
e.SizeBytes,
|
e.SizeBytes,
|
||||||
ItemCount = e.ItemIds.Count
|
ItemCount = e.ItemCount
|
||||||
})
|
})
|
||||||
.Cast<object>()
|
.Cast<object>()
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -229,7 +184,8 @@ public sealed class ItemsProxyCache
|
|||||||
long totalMs,
|
long totalMs,
|
||||||
long upstreamMs,
|
long upstreamMs,
|
||||||
long transformMs,
|
long transformMs,
|
||||||
long sizeBytes)
|
long sizeBytes,
|
||||||
|
bool inFlightCoalesced = false)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@@ -239,6 +195,7 @@ public sealed class ItemsProxyCache
|
|||||||
CreatedUtc = DateTimeOffset.UtcNow,
|
CreatedUtc = DateTimeOffset.UtcNow,
|
||||||
CacheHit = cacheHit,
|
CacheHit = cacheHit,
|
||||||
CacheStored = cacheStored,
|
CacheStored = cacheStored,
|
||||||
|
InFlightCoalesced = inFlightCoalesced,
|
||||||
StatusCode = statusCode,
|
StatusCode = statusCode,
|
||||||
ItemCount = itemCount,
|
ItemCount = itemCount,
|
||||||
TotalMs = totalMs,
|
TotalMs = totalMs,
|
||||||
@@ -265,6 +222,7 @@ public sealed class ItemsProxyCache
|
|||||||
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
|
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
|
||||||
e.CacheHit,
|
e.CacheHit,
|
||||||
e.CacheStored,
|
e.CacheStored,
|
||||||
|
e.InFlightCoalesced,
|
||||||
e.StatusCode,
|
e.StatusCode,
|
||||||
e.ItemCount,
|
e.ItemCount,
|
||||||
e.TotalMs,
|
e.TotalMs,
|
||||||
@@ -283,26 +241,16 @@ public sealed class ItemsProxyCache
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
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);
|
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)
|
private void Remove(string key)
|
||||||
{
|
{
|
||||||
if (!_entries.Remove(key, out var existing))
|
if (!_entries.Remove(key, out var existing))
|
||||||
return;
|
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 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($"{request.Scheme}://{request.Host}{request.PathBase}/");
|
||||||
var baseAddress = new Uri(baseUri);
|
if (rawUrl.Contains('\\') || !Uri.TryCreate(baseAddress, rawUrl, out var uri) ||
|
||||||
var uri = new Uri(baseAddress, rawUrl.StartsWith('/') ? rawUrl : "/" + rawUrl);
|
uri.Scheme != baseAddress.Scheme || uri.Authority != baseAddress.Authority ||
|
||||||
if ((rawUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
uri.UserInfo.Length > 0 || uri.Fragment.Length > 0)
|
||||||
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))
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (!absolute.Host.Equals(baseAddress.Host, StringComparison.OrdinalIgnoreCase) ||
|
var pathBase = request.PathBase.Value ?? string.Empty;
|
||||||
absolute.Port != baseAddress.Port)
|
if (!uri.AbsolutePath.StartsWith(pathBase + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
return null;
|
return null;
|
||||||
|
var route = uri.AbsolutePath[pathBase.Length..];
|
||||||
uri = absolute;
|
if (!IsSupportedRoute(route))
|
||||||
}
|
return null;
|
||||||
|
var path = NormalizeUpstreamPath(route, userId);
|
||||||
var path = NormalizeUpstreamPath(uri.AbsolutePath, userId);
|
var builder = new UriBuilder(uri) { Path = pathBase + path };
|
||||||
var builder = new UriBuilder(uri) { Path = path };
|
|
||||||
var query = QueryHelpers.ParseQuery(builder.Query);
|
var query = QueryHelpers.ParseQuery(builder.Query);
|
||||||
var rawControls = new ItemsProxyControls(
|
var rawControls = new ItemsProxyControls(
|
||||||
GetQueryString(query, "SortBy"),
|
GetQueryString(query, "SortBy"),
|
||||||
@@ -48,36 +43,25 @@ public static class ItemsProxyRequestBuilder
|
|||||||
GetQueryString(query, "GenreIds"));
|
GetQueryString(query, "GenreIds"));
|
||||||
var hasLocalGenreFilter = multilangEnabled && ParseLocalGenreIds(rawControls.GenreIds).Length > 0;
|
var hasLocalGenreFilter = multilangEnabled && ParseLocalGenreIds(rawControls.GenreIds).Length > 0;
|
||||||
var useLocalControls = multilangEnabled && ShouldUseLocalControls(path, query, hasLocalGenreFilter);
|
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
|
var controls = useLocalControls
|
||||||
? rawControls
|
? rawControls
|
||||||
: new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds);
|
: new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds, ApplyLocalPaging: false);
|
||||||
var localCacheKey = string.Join('|',
|
|
||||||
controls.SortBy,
|
|
||||||
controls.SortOrder,
|
|
||||||
controls.NameStartsWith,
|
|
||||||
controls.StartIndex.ToString(CultureInfo.InvariantCulture),
|
|
||||||
controls.Limit.ToString(CultureInfo.InvariantCulture),
|
|
||||||
controls.ClientLocale,
|
|
||||||
controls.GenreIds);
|
|
||||||
var pairs = query
|
var pairs = query
|
||||||
.Where(kv => !IsLocalProxyQuery(kv.Key, hasLocalGenreFilter, useLocalControls))
|
.Where(kv => !IsLocalProxyQuery(kv.Key, hasLocalGenreFilter, useLocalControls))
|
||||||
.SelectMany(kv => kv.Value.Select(v => KeyValuePair.Create<string, string?>(kv.Key, v ?? string.Empty)))
|
.SelectMany(kv => kv.Value.Select(v => KeyValuePair.Create<string, string?>(kv.Key, v ?? string.Empty)))
|
||||||
.ToList();
|
.ToList();
|
||||||
pairs.Add(KeyValuePair.Create<string, string?>("userId", userId));
|
pairs.Add(KeyValuePair.Create<string, string?>("userId", userId));
|
||||||
if (hasLocalGenreFilter)
|
var cacheQuery = QueryHelpers.AddQueryString(string.Empty, pairs.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)).TrimStart('?');
|
||||||
{
|
builder.Query = cacheQuery;
|
||||||
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 normalizedForCache = builder.Path + (cacheQuery.Length > 0 ? "?" + cacheQuery : string.Empty);
|
var normalizedForCache = builder.Path + (cacheQuery.Length > 0 ? "?" + cacheQuery : string.Empty);
|
||||||
return new ItemsProxyRequest(
|
return new ItemsProxyRequest(
|
||||||
builder.Uri,
|
builder.Uri,
|
||||||
controls,
|
controls,
|
||||||
$"{userId}|{normalizedForCache}|{localCacheKey}",
|
$"{userId}|{normalizedForCache}",
|
||||||
normalizedForCache,
|
normalizedForCache,
|
||||||
IsGenresPath(path),
|
IsGenresPath(path),
|
||||||
GenreMediaFromQuery(query));
|
GenreMediaFromQuery(query));
|
||||||
@@ -92,6 +76,22 @@ public static class ItemsProxyRequestBuilder
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.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(
|
private static bool ShouldUseLocalControls(
|
||||||
string normalizedPath,
|
string normalizedPath,
|
||||||
IReadOnlyDictionary<string, StringValues> query,
|
IReadOnlyDictionary<string, StringValues> query,
|
||||||
@@ -136,17 +136,14 @@ public static class ItemsProxyRequestBuilder
|
|||||||
key.Equals("Limit", StringComparison.OrdinalIgnoreCase) ||
|
key.Equals("Limit", StringComparison.OrdinalIgnoreCase) ||
|
||||||
key.Equals("NameStartsWith", StringComparison.OrdinalIgnoreCase))) ||
|
key.Equals("NameStartsWith", StringComparison.OrdinalIgnoreCase))) ||
|
||||||
(hasGenreFilter && (key.Equals("GenreIds", StringComparison.OrdinalIgnoreCase) ||
|
(hasGenreFilter && (key.Equals("GenreIds", StringComparison.OrdinalIgnoreCase) ||
|
||||||
key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase) ||
|
key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase)));
|
||||||
key.Equals("EnableImageTypes", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
key.Equals("Fields", StringComparison.OrdinalIgnoreCase)));
|
|
||||||
|
|
||||||
private static string NormalizeUpstreamPath(string path, string userId)
|
private static string NormalizeUpstreamPath(string path, string userId)
|
||||||
{
|
{
|
||||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||||
if (segments.Count >= 2 &&
|
if (segments.Count >= 2 &&
|
||||||
segments[0].Equals("Users", StringComparison.OrdinalIgnoreCase) &&
|
segments[0].Equals("Users", StringComparison.OrdinalIgnoreCase) &&
|
||||||
segments[1].Length == 32 &&
|
Guid.TryParse(segments[1], out _))
|
||||||
segments[1].All(Uri.IsHexDigit))
|
|
||||||
{
|
{
|
||||||
segments[1] = userId;
|
segments[1] = userId;
|
||||||
return "/" + string.Join('/', segments);
|
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 StartIndex,
|
||||||
int Limit,
|
int Limit,
|
||||||
string ClientLocale,
|
string ClientLocale,
|
||||||
string GenreIds);
|
string GenreIds,
|
||||||
|
bool ApplyLocalPaging = true);
|
||||||
|
|
||||||
public static class ItemsProxySorting
|
public static class ItemsProxySorting
|
||||||
{
|
{
|
||||||
@@ -29,16 +30,21 @@ public static class ItemsProxySorting
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles)
|
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))
|
if (!TryGetItemsArray(root, out var itemsArray))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var items = itemsArray.OfType<JsonObject>().ToList();
|
var items = itemsArray.OfType<JsonObject>().ToList();
|
||||||
var specs = BuildSortSpecs(controls.SortBy);
|
var specs = BuildSortSpecs(controls.SortBy);
|
||||||
if (specs.Length > 0 && items.Count > 1)
|
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;
|
var filteredCount = items.Count;
|
||||||
items = Slice(items, controls.StartIndex, controls.Limit);
|
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 (!ignoreArticles)
|
||||||
if (locale.Length == 0)
|
return [];
|
||||||
locale = "en";
|
|
||||||
|
|
||||||
var configured = configuredArticles.ToArray();
|
var always = configuredArticles
|
||||||
var custom = configured.FirstOrDefault(e => e.Language.Equals(locale, StringComparison.OrdinalIgnoreCase))
|
.Where(e => e.AlwaysApply == true)
|
||||||
?? configured.FirstOrDefault(e => locale.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase));
|
.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)
|
if (custom is not null)
|
||||||
return SplitArticles(custom.Articles);
|
return SplitArticles(custom.Articles);
|
||||||
|
|
||||||
var builtIns = articleCatalog.GetBuiltIns();
|
var builtIns = articleCatalog.GetBuiltIns();
|
||||||
if (builtIns.TryGetValue(locale, out var exact))
|
if (builtIns.TryGetValue(language, out var exact))
|
||||||
return exact;
|
return exact;
|
||||||
|
|
||||||
var dash = locale.IndexOf('-', StringComparison.Ordinal);
|
var dash = language.IndexOf('-', StringComparison.Ordinal);
|
||||||
return dash > 0 && builtIns.TryGetValue(locale[..dash], out var languageOnly)
|
return dash > 0 && builtIns.TryGetValue(language[..dash], out var languageOnly)
|
||||||
? languageOnly
|
? languageOnly
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
@@ -149,12 +175,12 @@ public static class ItemsProxySorting
|
|||||||
_ => SortKind.Text
|
_ => 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)
|
var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true;
|
.FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
var comparer = StringComparer.Create(culture, 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) =>
|
values.Sort((left, right) =>
|
||||||
{
|
{
|
||||||
@@ -197,14 +223,14 @@ public static class ItemsProxySorting
|
|||||||
|
|
||||||
private readonly record struct SortValue(bool HasValue, string Text, double Number);
|
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();
|
prefix = (prefix ?? string.Empty).Trim();
|
||||||
if (prefix.Length == 0)
|
if (prefix.Length == 0)
|
||||||
return items;
|
return items;
|
||||||
|
|
||||||
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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +253,9 @@ public static class ItemsProxySorting
|
|||||||
var word = article.Trim();
|
var word = article.Trim();
|
||||||
if (word.Length == 0 || text.Length <= word.Length)
|
if (word.Length == 0 || text.Length <= word.Length)
|
||||||
continue;
|
continue;
|
||||||
if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase))
|
var attachedArticle = word.EndsWith("'", StringComparison.Ordinal);
|
||||||
return text[(word.Length + 1)..].TrimStart();
|
if (text.StartsWith(word + (attachedArticle ? string.Empty : " "), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return text[word.Length..].TrimStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Jellyfin.Plugin.Multilang.Services;
|
|||||||
|
|
||||||
public readonly record struct ItemsProxyTransformResult(string Body, string[] ItemIds);
|
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);
|
public readonly record struct ItemsProxyResolvedAsset(bool Change, string? Value);
|
||||||
|
|
||||||
@@ -22,6 +22,9 @@ public readonly record struct ItemsProxyCategoryMatch(
|
|||||||
UserCategoryRule? Effective,
|
UserCategoryRule? Effective,
|
||||||
bool DuplicateLabelResolved);
|
bool DuplicateLabelResolved);
|
||||||
|
|
||||||
|
public sealed record ItemsProxyResolutionAttempt(
|
||||||
|
string Action, string Source, string LookupKey, bool HasValue, bool Chosen, string? Value, string Reason);
|
||||||
|
|
||||||
public sealed class ItemsProxyTransformer
|
public sealed class ItemsProxyTransformer
|
||||||
{
|
{
|
||||||
private readonly TranslationStore _store;
|
private readonly TranslationStore _store;
|
||||||
@@ -61,9 +64,9 @@ public sealed class ItemsProxyTransformer
|
|||||||
if (ids.Length == 0)
|
if (ids.Length == 0)
|
||||||
return new ItemsProxyTransformResult(body, ids);
|
return new ItemsProxyTransformResult(body, ids);
|
||||||
|
|
||||||
|
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale);
|
var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale);
|
||||||
var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale);
|
var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale);
|
||||||
var sortArticles = ItemsProxySorting.GetSortArticles(_articleCatalog, Plugin.Instance?.Configuration?.ArticleEntries ?? [], sortLocale);
|
|
||||||
|
|
||||||
var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : [];
|
var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : [];
|
||||||
if (rules.Enabled || localGenreIds.Length > 0)
|
if (rules.Enabled || localGenreIds.Length > 0)
|
||||||
@@ -80,10 +83,26 @@ public sealed class ItemsProxyTransformer
|
|||||||
ids = GetItemIds(items);
|
ids = GetItemIds(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rules.Enabled)
|
var titleLanguages = rules.Enabled
|
||||||
ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem);
|
? 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);
|
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))
|
foreach (var action in GetAllActionTokens(category.FieldActionLists))
|
||||||
AddLanguageAction(langs, action);
|
AddLanguageAction(langs, action);
|
||||||
foreach (var action in (category.FieldActions ?? []).Values)
|
|
||||||
AddLanguageAction(langs, action);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var action in GetAllActionTokens(rules.FallbackFieldActions))
|
foreach (var action in GetAllActionTokens(rules.FallbackFieldActions))
|
||||||
@@ -168,29 +185,28 @@ public sealed class ItemsProxyTransformer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public FactsData GetClassificationFacts(UserRulesDocument rules, FactsData facts)
|
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 ||
|
if (rules.TrustTmdbCollections)
|
||||||
!facts.Kind.Equals("collection", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
!Guid.TryParseExact(facts.ItemId, "N", out var itemGuid))
|
|
||||||
{
|
|
||||||
return facts;
|
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);
|
public static FactsData AggregateCollectionFacts(FactsData facts, FactsData[] childFacts)
|
||||||
var children = (item as Folder)?.GetLinkedChildren()
|
{
|
||||||
.Where(child => child.GetType().Name.Equals("Movie", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToArray() ?? [];
|
|
||||||
|
|
||||||
var childIds = children.Select(child => TranslationStore.ToItemId32(child.Id)).ToArray();
|
|
||||||
if (childIds.Length == 0)
|
|
||||||
return facts;
|
|
||||||
|
|
||||||
var childFacts = _store.GetFactsForItems(childIds).Values
|
|
||||||
.Where(child => child.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToArray();
|
|
||||||
if (childFacts.Length == 0)
|
if (childFacts.Length == 0)
|
||||||
return facts;
|
return facts;
|
||||||
|
|
||||||
return facts with
|
return facts with
|
||||||
{
|
{
|
||||||
OriginalLanguage = SharedSingle(childFacts.Select(child => child.OriginalLanguage)),
|
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))
|
if (actions is not null && actions.TryGetValue(field, out var list))
|
||||||
return list;
|
return list;
|
||||||
|
|
||||||
return category is not null && category.FieldActions is not null
|
return [JellyfinAction];
|
||||||
? LegacyActionToList(field, category.FieldActions)
|
|
||||||
: [JellyfinAction];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ItemsProxyResolvedText ResolveField(
|
public static ItemsProxyResolvedText ResolveField(
|
||||||
string field,
|
string field, IReadOnlyList<string> actions, FactsData facts,
|
||||||
IReadOnlyList<string> actions,
|
Dictionary<string, Dictionary<string, string>>? byLang,
|
||||||
FactsData facts,
|
Action<ItemsProxyResolutionAttempt>? trace = null)
|
||||||
Dictionary<string, Dictionary<string, string>>? byLang)
|
|
||||||
{
|
{
|
||||||
foreach (var action in actions)
|
foreach (var action in actions)
|
||||||
{
|
{
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
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);
|
trace?.Invoke(new(action, JellyfinAction, "", true, true, null, "Use Jellyfin value"));
|
||||||
if (!string.IsNullOrWhiteSpace(originalValue))
|
return new(false, null);
|
||||||
return new ItemsProxyResolvedText(true, originalValue);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
var original = action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase);
|
||||||
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
if (!original && !action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
continue;
|
continue;
|
||||||
|
var lang = original ? OriginalAction : action[LanguagePrefix.Length..];
|
||||||
var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field);
|
var originalTitle = original && field == TitleField && !string.IsNullOrWhiteSpace(facts.OriginalTitle);
|
||||||
if (!string.IsNullOrWhiteSpace(value))
|
var value = originalTitle ? facts.OriginalTitle : GetTranslatedField(byLang, lang, field);
|
||||||
return new ItemsProxyResolvedText(true, value);
|
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 == TitleField ? new(false, null) : new(true, string.Empty);
|
||||||
return field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? new ItemsProxyResolvedText(false, null)
|
|
||||||
: new ItemsProxyResolvedText(true, string.Empty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ItemsProxyResolvedAsset ResolveAsset(
|
public static ItemsProxyResolvedAsset ResolveAsset(
|
||||||
string kind,
|
string kind, IReadOnlyList<string> actions,
|
||||||
IReadOnlyList<string> actions,
|
Dictionary<string, Dictionary<string, string>>? byKind,
|
||||||
Dictionary<string, Dictionary<string, string>>? byKind)
|
Action<ItemsProxyResolutionAttempt>? trace = null)
|
||||||
{
|
{
|
||||||
foreach (var action in actions)
|
foreach (var action in actions)
|
||||||
{
|
{
|
||||||
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
if (action.Equals(JellyfinAction, StringComparison.OrdinalIgnoreCase))
|
||||||
return new ItemsProxyResolvedAsset(false, null);
|
|
||||||
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var originalValue = GetAsset(byKind, kind, OriginalAction);
|
trace?.Invoke(new(action, JellyfinAction, "", true, true, null, "Use Jellyfin image"));
|
||||||
if (!string.IsNullOrWhiteSpace(originalValue))
|
return new(false, null);
|
||||||
return new ItemsProxyResolvedAsset(true, originalValue);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
var original = action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase);
|
||||||
if (!action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
if (!original && !action.StartsWith(LanguagePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
continue;
|
continue;
|
||||||
|
var lang = original ? OriginalAction : action[LanguagePrefix.Length..];
|
||||||
var value = GetAsset(byKind, kind, action[LanguagePrefix.Length..]);
|
var value = GetAsset(byKind, kind, lang);
|
||||||
if (!string.IsNullOrWhiteSpace(value))
|
var found = !string.IsNullOrWhiteSpace(value);
|
||||||
return new ItemsProxyResolvedAsset(true, value);
|
trace?.Invoke(new(action, "Asset", kind + "/" + lang, found, found, value,
|
||||||
|
found ? "Asset found" : "Asset missing"));
|
||||||
|
if (found)
|
||||||
|
return new(true, value);
|
||||||
}
|
}
|
||||||
|
return kind == PosterKind ? new(false, null) : new(true, string.Empty);
|
||||||
return kind.Equals(PosterKind, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? new ItemsProxyResolvedAsset(false, null)
|
|
||||||
: new ItemsProxyResolvedAsset(true, string.Empty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? GetTranslatedField(
|
public static string? GetTranslatedField(
|
||||||
@@ -347,7 +350,7 @@ public sealed class ItemsProxyTransformer
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyRules(
|
private Dictionary<string, string> ApplyRules(
|
||||||
IEnumerable<JsonObject> items,
|
IEnumerable<JsonObject> items,
|
||||||
string[] ids,
|
string[] ids,
|
||||||
UserRulesDocument rules,
|
UserRulesDocument rules,
|
||||||
@@ -357,6 +360,8 @@ public sealed class ItemsProxyTransformer
|
|||||||
var langs = GetNeededLanguages(rules);
|
var langs = GetNeededLanguages(rules);
|
||||||
var translations = _store.GetTranslations(ids, langs);
|
var translations = _store.GetTranslations(ids, langs);
|
||||||
var assets = _store.GetAssets(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 movieGenreNames = _store.GetGenreNames("movie", clientLocale);
|
||||||
var tvGenreNames = _store.GetGenreNames("tv", clientLocale);
|
var tvGenreNames = _store.GetGenreNames("tv", clientLocale);
|
||||||
|
|
||||||
@@ -367,12 +372,14 @@ public sealed class ItemsProxyTransformer
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
translations.TryGetValue(itemId, out var byLang);
|
translations.TryGetValue(itemId, out var byLang);
|
||||||
var classificationFacts = GetClassificationFacts(rules, facts);
|
var classificationFacts = classification[itemId];
|
||||||
var category = PickCategoryMatch(rules, classificationFacts).Effective;
|
var category = PickCategoryMatch(rules, classificationFacts).Effective;
|
||||||
|
|
||||||
var title = ResolveField(TitleField, GetActionList(rules, category, TitleField), facts, byLang);
|
var title = ResolveField(TitleField, GetActionList(rules, category, TitleField), facts, byLang);
|
||||||
if (title.Change)
|
if (title.Change)
|
||||||
item["Name"] = title.Value ?? string.Empty;
|
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);
|
var overview = ResolveField(OverviewField, GetActionList(rules, category, OverviewField), facts, byLang);
|
||||||
if (overview.Change)
|
if (overview.Change)
|
||||||
@@ -395,6 +402,27 @@ public sealed class ItemsProxyTransformer
|
|||||||
ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind));
|
ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind));
|
||||||
ApplyGenres(item, facts, movieGenreNames, tvGenreNames);
|
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)
|
private static void ApplyImageAsset(JsonObject item, string imageType, ItemsProxyResolvedAsset asset)
|
||||||
@@ -543,15 +571,6 @@ public sealed class ItemsProxyTransformer
|
|||||||
langs.Add(action[LanguagePrefix.Length..]);
|
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)
|
private static string SharedSingle(IEnumerable<string> values)
|
||||||
{
|
{
|
||||||
var unique = 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)}";
|
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 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).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
return null;
|
return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
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);
|
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 readonly TimeSpan _minimumSpacing;
|
||||||
private long _lastStartTicks;
|
private long _lastStartTicks;
|
||||||
|
private readonly SemaphoreSlim _admission = new(1, 1);
|
||||||
|
|
||||||
public FetchRateLimiter(TimeSpan minimumSpacing)
|
public FetchRateLimiter(TimeSpan minimumSpacing)
|
||||||
{
|
{
|
||||||
@@ -14,15 +15,18 @@ public sealed class FetchRateLimiter
|
|||||||
|
|
||||||
public async Task WaitAsync(CancellationToken cancellationToken)
|
public async Task WaitAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var last = Interlocked.Read(ref _lastStartTicks);
|
await _admission.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (last != 0)
|
try
|
||||||
{
|
{
|
||||||
var elapsed = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - last) / (double)Stopwatch.Frequency);
|
if (_lastStartTicks != 0)
|
||||||
var remaining = _minimumSpacing - elapsed;
|
{
|
||||||
|
var remaining = _minimumSpacing - Stopwatch.GetElapsedTime(_lastStartTicks);
|
||||||
if (remaining > TimeSpan.Zero)
|
if (remaining > TimeSpan.Zero)
|
||||||
await Task.Delay(remaining, cancellationToken).ConfigureAwait(false);
|
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(
|
public static async Task<HttpResponseMessage> SendWithRetryAsync(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
HttpRequestMessage request,
|
HttpRequestMessage request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
FetchRateLimiter? rateLimiter = null)
|
||||||
{
|
{
|
||||||
const int maxAttempts = 3;
|
const int maxAttempts = 3;
|
||||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||||
{
|
{
|
||||||
try
|
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)
|
if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts)
|
||||||
{
|
{
|
||||||
Record(request, "HTTP " + (int)response.StatusCode, attempt);
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
var delay = GetRetryDelay(response, attempt);
|
var delay = GetRetryDelay(response, attempt);
|
||||||
response.Dispose();
|
response.Dispose();
|
||||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(delay < TimeSpan.Zero ? TimeSpan.Zero : delay, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested && ex is HttpRequestException or OperationCanceledException)
|
||||||
{
|
|
||||||
if (attempt >= maxAttempts)
|
|
||||||
{
|
{
|
||||||
Record(request, ex.GetType().Name, attempt);
|
Record(request, ex.GetType().Name, attempt);
|
||||||
|
if (attempt >= maxAttempts)
|
||||||
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ public sealed class TmdbClient
|
|||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
throw new InvalidOperationException("TMDb API key is required.");
|
throw new InvalidOperationException("TMDb API key is required.");
|
||||||
|
|
||||||
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
var http = _httpClientFactory.CreateClient();
|
var http = _httpClientFactory.CreateClient();
|
||||||
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}?api_key={Uri.EscapeDataString(apiKey)}&language={Uri.EscapeDataString(language)}";
|
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 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)
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
return null;
|
return null;
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -58,11 +57,10 @@ public sealed class TmdbClient
|
|||||||
if (item.Kind is "tvepisode")
|
if (item.Kind is "tvepisode")
|
||||||
return new TmdbImages([], [], []);
|
return new TmdbImages([], [], []);
|
||||||
|
|
||||||
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
var http = _httpClientFactory.CreateClient();
|
var http = _httpClientFactory.CreateClient();
|
||||||
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}/images?api_key={Uri.EscapeDataString(apiKey)}";
|
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 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)
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
return null;
|
return null;
|
||||||
response.EnsureSuccessStatusCode();
|
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;
|
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
|
||||||
|
|
||||||
public sealed class RefreshCoordinator
|
public sealed class RefreshCoordinator : IDisposable
|
||||||
{
|
{
|
||||||
private sealed class QueueEntry
|
private sealed class QueueEntry
|
||||||
{
|
{
|
||||||
@@ -20,21 +20,22 @@ public sealed class RefreshCoordinator
|
|||||||
public required TaskCompletionSource<bool> Completion { get; init; }
|
public required TaskCompletionSource<bool> Completion { get; init; }
|
||||||
|
|
||||||
public long Sequence { get; init; }
|
public long Sequence { get; init; }
|
||||||
|
public bool Active { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private readonly Dictionary<string, QueueEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, QueueEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Queue<RefreshRecentItem> _recent = new();
|
private readonly Queue<RefreshRecentItem> _recent = new();
|
||||||
private readonly SemaphoreSlim _signal = new(0, int.MaxValue);
|
private readonly SemaphoreSlim _signal = new(0, int.MaxValue);
|
||||||
private readonly ItemsProxyCache _itemsProxyCache;
|
|
||||||
private long _sequence;
|
private long _sequence;
|
||||||
private QueueEntry? _active;
|
private readonly CancellationTokenSource _stop = new();
|
||||||
|
|
||||||
public SemaphoreSlim ScanMutex { get; } = new(1, 1);
|
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);
|
_ = Task.Run(WorkerLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ public sealed class RefreshCoordinator
|
|||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_stop.IsCancellationRequested, this);
|
||||||
if (_entries.TryGetValue(itemId, out var existing))
|
if (_entries.TryGetValue(itemId, out var existing))
|
||||||
{
|
{
|
||||||
if (jobType > existing.JobType)
|
if (jobType > existing.JobType)
|
||||||
@@ -64,7 +66,7 @@ public sealed class RefreshCoordinator
|
|||||||
if (sourceTier > existing.SourceTier)
|
if (sourceTier > existing.SourceTier)
|
||||||
existing.SourceTier = sourceTier;
|
existing.SourceTier = sourceTier;
|
||||||
|
|
||||||
return existing.Completion.Task;
|
return cancellationToken.CanBeCanceled ? existing.Completion.Task.WaitAsync(cancellationToken) : existing.Completion.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entry = new QueueEntry
|
var entry = new QueueEntry
|
||||||
@@ -80,7 +82,7 @@ public sealed class RefreshCoordinator
|
|||||||
|
|
||||||
_entries.Add(itemId, entry);
|
_entries.Add(itemId, entry);
|
||||||
_signal.Release();
|
_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)
|
while (true)
|
||||||
{
|
{
|
||||||
await _signal.WaitAsync().ConfigureAwait(false);
|
try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
QueueEntry? entry;
|
QueueEntry? entry;
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -102,12 +105,24 @@ public sealed class RefreshCoordinator
|
|||||||
var error = string.Empty;
|
var error = string.Empty;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
RefreshJobType executing;
|
||||||
|
Func<RefreshJobType, CancellationToken, Task<bool>> work;
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
_active = entry;
|
{
|
||||||
|
executing = entry.JobType;
|
||||||
ok = await entry.Work(entry.JobType, CancellationToken.None).ConfigureAwait(false);
|
work = entry.Work;
|
||||||
if (ok)
|
}
|
||||||
_itemsProxyCache.InvalidateItems([entry.ItemId]);
|
ok |= await work(executing, _stop.Token).ConfigureAwait(false);
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (entry.JobType > executing)
|
||||||
|
continue;
|
||||||
|
_entries.Remove(entry.ItemId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
entry.Completion.TrySetResult(ok);
|
entry.Completion.TrySetResult(ok);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -120,8 +135,7 @@ public sealed class RefreshCoordinator
|
|||||||
sw.Stop();
|
sw.Stop();
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(_active, entry))
|
if (_entries.TryGetValue(entry.ItemId, out var current) && ReferenceEquals(current, entry))
|
||||||
_active = null;
|
|
||||||
_entries.Remove(entry.ItemId);
|
_entries.Remove(entry.ItemId);
|
||||||
_recent.Enqueue(new RefreshRecentItem(
|
_recent.Enqueue(new RefreshRecentItem(
|
||||||
entry.ItemId,
|
entry.ItemId,
|
||||||
@@ -144,11 +158,14 @@ public sealed class RefreshCoordinator
|
|||||||
if (_entries.Count == 0)
|
if (_entries.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return _entries.Values
|
var entry = _entries.Values
|
||||||
|
.Where(e => !e.Active)
|
||||||
.OrderByDescending(e => e.SourceTier)
|
.OrderByDescending(e => e.SourceTier)
|
||||||
.ThenBy(e => e.WorkClass)
|
.ThenBy(e => e.WorkClass)
|
||||||
.ThenBy(e => e.Sequence)
|
.ThenBy(e => e.Sequence)
|
||||||
.First();
|
.FirstOrDefault();
|
||||||
|
if (entry is not null) entry.Active = true;
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public RefreshQueueDiagnostics GetDiagnostics()
|
public RefreshQueueDiagnostics GetDiagnostics()
|
||||||
@@ -156,7 +173,7 @@ public sealed class RefreshCoordinator
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var queued = _entries.Values
|
var queued = _entries.Values
|
||||||
.Where(e => !ReferenceEquals(e, _active))
|
.Where(e => !e.Active)
|
||||||
.OrderByDescending(e => e.SourceTier)
|
.OrderByDescending(e => e.SourceTier)
|
||||||
.ThenBy(e => e.WorkClass)
|
.ThenBy(e => e.WorkClass)
|
||||||
.ThenBy(e => e.Sequence)
|
.ThenBy(e => e.Sequence)
|
||||||
@@ -166,7 +183,7 @@ public sealed class RefreshCoordinator
|
|||||||
return new RefreshQueueDiagnostics(
|
return new RefreshQueueDiagnostics(
|
||||||
queued.Length,
|
queued.Length,
|
||||||
queued,
|
queued,
|
||||||
_active is null ? null : ToDiagnostics(_active),
|
_entries.Values.Where(e => e.Active).Select(ToDiagnostics).ToArray(),
|
||||||
_recent.Reverse().ToArray());
|
_recent.Reverse().ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,4 +195,15 @@ public sealed class RefreshCoordinator
|
|||||||
entry.WorkClass.ToString(),
|
entry.WorkClass.ToString(),
|
||||||
entry.JobType.ToString(),
|
entry.JobType.ToString(),
|
||||||
entry.Sequence);
|
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(
|
public sealed record RefreshQueueDiagnostics(
|
||||||
int QueuedCount,
|
int QueuedCount,
|
||||||
RefreshQueueEntry[] Queued,
|
RefreshQueueEntry[] Queued,
|
||||||
RefreshQueueEntry? Active,
|
RefreshQueueEntry[] ActiveItems,
|
||||||
RefreshRecentItem[] Recent);
|
RefreshRecentItem[] Recent);
|
||||||
|
|
||||||
public sealed record RefreshScanDiagnostics(
|
public sealed record RefreshScanDiagnostics(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System.Diagnostics;
|
|||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Plugin.Multilang.Configuration;
|
using Jellyfin.Plugin.Multilang.Configuration;
|
||||||
using Jellyfin.Plugin.Multilang.Data;
|
using Jellyfin.Plugin.Multilang.Data;
|
||||||
using Jellyfin.Plugin.Multilang.Services.Assets;
|
|
||||||
using Jellyfin.Plugin.Multilang.Services.Providers;
|
using Jellyfin.Plugin.Multilang.Services.Providers;
|
||||||
using static Jellyfin.Plugin.Multilang.MultilangConstants;
|
using static Jellyfin.Plugin.Multilang.MultilangConstants;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -16,9 +15,7 @@ public sealed class RefreshService
|
|||||||
{
|
{
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly TranslationStore _store;
|
private readonly TranslationStore _store;
|
||||||
private readonly TmdbClient _tmdbClient;
|
private readonly ItemFetcher _fetcher;
|
||||||
private readonly FanartClient _fanartClient;
|
|
||||||
private readonly AssetStorageService _assetStorage;
|
|
||||||
private readonly RefreshCoordinator _coordinator;
|
private readonly RefreshCoordinator _coordinator;
|
||||||
private readonly ILogger<RefreshService> _logger;
|
private readonly ILogger<RefreshService> _logger;
|
||||||
private readonly object _diagnosticsLock = new();
|
private readonly object _diagnosticsLock = new();
|
||||||
@@ -27,17 +24,13 @@ public sealed class RefreshService
|
|||||||
public RefreshService(
|
public RefreshService(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
TranslationStore store,
|
TranslationStore store,
|
||||||
TmdbClient tmdbClient,
|
ItemFetcher fetcher,
|
||||||
FanartClient fanartClient,
|
|
||||||
AssetStorageService assetStorage,
|
|
||||||
RefreshCoordinator coordinator,
|
RefreshCoordinator coordinator,
|
||||||
ILogger<RefreshService> logger)
|
ILogger<RefreshService> logger)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_store = store;
|
_store = store;
|
||||||
_tmdbClient = tmdbClient;
|
_fetcher = fetcher;
|
||||||
_fanartClient = fanartClient;
|
|
||||||
_assetStorage = assetStorage;
|
|
||||||
_coordinator = coordinator;
|
_coordinator = coordinator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -151,7 +144,6 @@ public sealed class RefreshService
|
|||||||
UpdateRunningScan();
|
UpdateRunningScan();
|
||||||
var items = GetAllItemsWithTmdbId();
|
var items = GetAllItemsWithTmdbId();
|
||||||
var now = TranslationStore.NowUnixUtc();
|
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 fullCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForExistingData)).TotalSeconds;
|
||||||
var ordered = items.OrderBy(i => i.WorkClass).ToList();
|
var ordered = items.OrderBy(i => i.WorkClass).ToList();
|
||||||
itemCount = ordered.Count;
|
itemCount = ordered.Count;
|
||||||
@@ -162,7 +154,14 @@ public sealed class RefreshService
|
|||||||
// Run local housekeeping only from full-library scans. ItemsProxy browsing may enqueue
|
// 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
|
// missing items, but it must not delete local assets just because the admin briefly
|
||||||
// changed artwork storage mode.
|
// 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)
|
if (localCleanup.TranslationsDeleted > 0 || localCleanup.AssetsDeleted > 0 || localCleanup.GenresDeleted > 0 || localCleanup.AssetFilesDeleted > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_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);
|
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
|
||||||
var index = 0;
|
var index = 0;
|
||||||
UpdateRunningScan();
|
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();
|
ct.ThrowIfCancellationRequested();
|
||||||
index++;
|
var scanIndex = Interlocked.Increment(ref index);
|
||||||
SetCurrent(item, index, "checking");
|
|
||||||
var status = _store.GetFactsStatus(item.ItemId);
|
var status = _store.GetFactsStatus(item.ItemId);
|
||||||
var isNew = !status.Exists;
|
var isNew = !status.Exists;
|
||||||
var missingConfiguredData = jobType == RefreshJobType.Missing && status.Exists && ItemNeedsMissingRefresh(item, cfg, langs: null);
|
var fullDue = jobType == RefreshJobType.Full && (!status.Exists || status.FullCheckedAt < fullCutoff);
|
||||||
var due = jobType == RefreshJobType.Full
|
var due = fullDue || (jobType == RefreshJobType.Missing
|
||||||
? !status.Exists || status.FullCheckedAt < fullCutoff
|
? !status.Exists || _fetcher.NeedsRefresh(item, cfg)
|
||||||
: !status.Exists || status.MissingCheckedAt < missingCutoff || missingConfiguredData;
|
: _fetcher.NeedsLocalWork(item.ItemId, cfg));
|
||||||
|
|
||||||
if (!due)
|
if (!due)
|
||||||
{
|
{
|
||||||
skippedNotDueCount++;
|
Interlocked.Increment(ref skippedNotDueCount);
|
||||||
if (cfg.VerboseLogging && cfg.EnableLogging)
|
if (cfg.VerboseLogging && cfg.EnableLogging)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due",
|
"Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due",
|
||||||
index,
|
scanIndex,
|
||||||
ordered.Count,
|
ordered.Count,
|
||||||
item.ItemId,
|
item.ItemId,
|
||||||
item.DisplayName,
|
item.DisplayName,
|
||||||
item.Kind);
|
item.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
|
lock (scanLock)
|
||||||
UpdateRunningScan("skipped-not-due");
|
{
|
||||||
continue;
|
SetCurrent(item, completed + 1, "skipped-not-due");
|
||||||
|
progress.Report(++completed * 100.0 / Math.Max(1, ordered.Count));
|
||||||
|
UpdateRunningScan();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var scanIndex = index;
|
var actualJobType = fullDue || !status.Exists ? RefreshJobType.Full
|
||||||
var actualJobType = jobType == RefreshJobType.Full || !status.Exists ? RefreshJobType.Full : RefreshJobType.Missing;
|
: jobType == RefreshJobType.Full ? RefreshJobType.Aggregate : RefreshJobType.Missing;
|
||||||
dueCount++;
|
Interlocked.Increment(ref dueCount);
|
||||||
SetCurrent(item, scanIndex, "fetching");
|
|
||||||
Log(
|
Log(
|
||||||
cfg.EnableLogging,
|
cfg.EnableLogging,
|
||||||
LogLevel.Information,
|
LogLevel.Information,
|
||||||
@@ -229,19 +235,30 @@ public sealed class RefreshService
|
|||||||
RefreshSourceTier.Background,
|
RefreshSourceTier.Background,
|
||||||
item.WorkClass,
|
item.WorkClass,
|
||||||
actualJobType,
|
actualJobType,
|
||||||
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, ordered.Count, ct).ConfigureAwait(false),
|
async (actualJobType, ct) => await RefreshItemCoreAsync(item, actualJobType, ct).ConfigureAwait(false),
|
||||||
cancellationToken).ConfigureAwait(false);
|
ct).ConfigureAwait(false);
|
||||||
|
lock (scanLock)
|
||||||
|
{
|
||||||
if (ok)
|
if (ok)
|
||||||
refreshedCount++;
|
refreshedCount++;
|
||||||
else
|
else
|
||||||
skippedNoDataCount++;
|
skippedNoDataCount++;
|
||||||
|
|
||||||
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
|
progress.Report(++completed * 100.0 / Math.Max(1, ordered.Count));
|
||||||
UpdateRunningScan(ok ? "refreshed" : "skipped-no-data");
|
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();
|
sw.Stop();
|
||||||
_store.SetLastScanStarted(now);
|
|
||||||
CompleteScan(true, string.Empty);
|
CompleteScan(true, string.Empty);
|
||||||
_logger.LogInformation(
|
_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}",
|
"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.");
|
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
|
||||||
ValidateRefreshConfiguration(cfg);
|
ValidateRefreshConfiguration(cfg);
|
||||||
@@ -279,14 +297,17 @@ public sealed class RefreshService
|
|||||||
return;
|
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 total = items.Count;
|
||||||
var index = 0;
|
var index = 0;
|
||||||
_logger.LogInformation("Manual Multilang refresh started item={ItemId} name={Name} items={Items} includeChildren={IncludeChildren}", itemId, root.Name ?? string.Empty, total, includeChildren);
|
_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 = Interlocked.Increment(ref index);
|
||||||
var scanIndex = index;
|
|
||||||
Log(
|
Log(
|
||||||
cfg.EnableLogging,
|
cfg.EnableLogging,
|
||||||
LogLevel.Information,
|
LogLevel.Information,
|
||||||
@@ -303,8 +324,8 @@ public sealed class RefreshService
|
|||||||
RefreshSourceTier.Manual,
|
RefreshSourceTier.Manual,
|
||||||
item.WorkClass,
|
item.WorkClass,
|
||||||
RefreshJobType.Full,
|
RefreshJobType.Full,
|
||||||
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, total, ct).ConfigureAwait(false),
|
async (actualJobType, ct) => await RefreshItemCoreAsync(item, actualJobType, ct).ConfigureAwait(false),
|
||||||
cancellationToken).ConfigureAwait(false);
|
ct).ConfigureAwait(false);
|
||||||
Log(
|
Log(
|
||||||
cfg.EnableLogging,
|
cfg.EnableLogging,
|
||||||
ok ? LogLevel.Information : LogLevel.Warning,
|
ok ? LogLevel.Information : LogLevel.Warning,
|
||||||
@@ -315,7 +336,7 @@ public sealed class RefreshService
|
|||||||
item.ItemId,
|
item.ItemId,
|
||||||
item.DisplayName,
|
item.DisplayName,
|
||||||
item.Kind);
|
item.Kind);
|
||||||
}
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
|
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
|
||||||
@@ -365,7 +386,7 @@ public sealed class RefreshService
|
|||||||
sourceTier,
|
sourceTier,
|
||||||
info.WorkClass,
|
info.WorkClass,
|
||||||
RefreshJobType.Full,
|
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)
|
CancellationToken.None)
|
||||||
.ContinueWith(
|
.ContinueWith(
|
||||||
task =>
|
task =>
|
||||||
@@ -388,245 +409,16 @@ public sealed class RefreshService
|
|||||||
|
|
||||||
private async Task<bool> RefreshItemCoreAsync(
|
private async Task<bool> RefreshItemCoreAsync(
|
||||||
RefreshItemInfo item,
|
RefreshItemInfo item,
|
||||||
PluginConfiguration cfg,
|
|
||||||
RefreshJobType jobType,
|
RefreshJobType jobType,
|
||||||
int scanIndex,
|
|
||||||
int totalItems,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var langs = NormalizeLanguages(cfg.Languages);
|
using var lease = await _store.EnterRefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (langs.Length == 0)
|
var plugin = Plugin.Instance ?? throw new InvalidOperationException("Plugin unavailable.");
|
||||||
throw new InvalidOperationException("No Multilang languages are configured.");
|
if (plugin.Uninstalling)
|
||||||
|
throw new OperationCanceledException("Plugin is being uninstalled.");
|
||||||
if (cfg.VerboseLogging && cfg.EnableLogging)
|
var cfg = plugin.Configuration;
|
||||||
_logger.LogInformation("[{Index}/{Total}] Fetching item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.Kind, item.TmdbId);
|
ValidateRefreshConfiguration(cfg);
|
||||||
|
return await _fetcher.RefreshAsync(item, cfg, jobType, cancellationToken).ConfigureAwait(false);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<RefreshItemInfo> GetAllItemsWithTmdbId()
|
private List<RefreshItemInfo> GetAllItemsWithTmdbId()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
const MENU_ID = "mlUserRulesMenuItem";
|
const MENU_ID = "mlUserRulesMenuItem";
|
||||||
const USER_RULES_ROUTE = "multilang/user-rules";
|
const USER_RULES_ROUTE = "multilang/user-rules";
|
||||||
let lastMenuItemId = "";
|
let lastMenuItemId = "";
|
||||||
let userRulesRenderRun = 0;
|
|
||||||
|
|
||||||
function getClientToken() {
|
function getClientToken() {
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +43,11 @@
|
|||||||
return parts.path === USER_RULES_ROUTE && parts.params.get("multilang") === "1";
|
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) {
|
function pathSegments(pathname) {
|
||||||
return String(pathname || "")
|
return String(pathname || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -120,7 +124,7 @@
|
|||||||
|
|
||||||
function ensureUserRulesLink() {
|
function ensureUserRulesLink() {
|
||||||
let menuItem = document.getElementById(MENU_ID);
|
let menuItem = document.getElementById(MENU_ID);
|
||||||
if (!(location.hash || "").toLowerCase().includes("mypreferences")) {
|
if (!isPreferencesMenuRoute()) {
|
||||||
menuItem?.remove();
|
menuItem?.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,8 @@
|
|||||||
return;
|
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];
|
const insertAfter = links[links.length - 1];
|
||||||
if (!insertAfter) return;
|
if (!insertAfter) return;
|
||||||
|
|
||||||
@@ -214,6 +219,7 @@
|
|||||||
|
|
||||||
function closeUserRulesShellIfInactive() {
|
function closeUserRulesShellIfInactive() {
|
||||||
if (isUserRulesRoute()) return;
|
if (isUserRulesRoute()) return;
|
||||||
|
document.querySelector("#ml-user-shell #ml-user")?._mlDispose?.();
|
||||||
document.getElementById("ml-user-shell")?.remove();
|
document.getElementById("ml-user-shell")?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,32 +242,25 @@
|
|||||||
const next = document.createElement("script");
|
const next = document.createElement("script");
|
||||||
[...script.attributes].forEach((attr) => next.setAttribute(attr.name, attr.value));
|
[...script.attributes].forEach((attr) => next.setAttribute(attr.name, attr.value));
|
||||||
if (script.src) {
|
if (script.src) {
|
||||||
next.onload = () => resolve();
|
next.onload = () => { next.remove(); resolve(); };
|
||||||
next.onerror = () => reject(new Error(`Failed to load ${script.src}`));
|
next.onerror = () => { next.remove(); reject(new Error(`Failed to load ${script.src}`)); };
|
||||||
next.src = script.src;
|
next.src = script.src;
|
||||||
} else {
|
} else {
|
||||||
next.text = script.textContent || "";
|
next.text = script.textContent || "";
|
||||||
}
|
}
|
||||||
document.body.appendChild(next);
|
document.body.appendChild(next);
|
||||||
if (!script.src) resolve();
|
if (!script.src) { next.remove(); resolve(); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderUserRulesPage(run) {
|
async function renderUserRulesPage(host) {
|
||||||
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";
|
|
||||||
host.innerHTML = "<div class=\"fieldDescription\">Loading Multilang settings...</div>";
|
host.innerHTML = "<div class=\"fieldDescription\">Loading Multilang settings...</div>";
|
||||||
|
|
||||||
const response = await fetch(`${BASE_PATH}/web/configurationpage?name=MultilangUser`, { cache: "no-store" });
|
const response = await fetch(`${BASE_PATH}/web/configurationpage?name=MultilangUser`, { cache: "no-store" });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const fragment = await response.text();
|
const fragment = await response.text();
|
||||||
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
|
if (!host.isConnected || !isUserRulesRoute()) return;
|
||||||
|
|
||||||
const parsed = new DOMParser().parseFromString(fragment, "text/html");
|
const parsed = new DOMParser().parseFromString(fragment, "text/html");
|
||||||
const sourcePage = parsed.getElementById("ml-user");
|
const sourcePage = parsed.getElementById("ml-user");
|
||||||
@@ -275,26 +274,26 @@
|
|||||||
page.id = "ml-user";
|
page.id = "ml-user";
|
||||||
page.className = "ml-root";
|
page.className = "ml-root";
|
||||||
while (sourceContent.firstChild) page.appendChild(sourceContent.firstChild);
|
while (sourceContent.firstChild) page.appendChild(sourceContent.firstChild);
|
||||||
host.replaceChildren(page);
|
host.textContent = "";
|
||||||
|
host.appendChild(page);
|
||||||
|
|
||||||
for (const script of scripts) {
|
for (const script of scripts) {
|
||||||
if (run !== userRulesRenderRun || !isUserRulesRoute()) return;
|
if (!host.isConnected || !isUserRulesRoute()) return;
|
||||||
await executeScript(script);
|
await executeScript(script);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleUserRulesPageRender() {
|
function scheduleUserRulesPageRender() {
|
||||||
userRulesRenderRun++;
|
|
||||||
if (!isUserRulesRoute()) {
|
if (!isUserRulesRoute()) {
|
||||||
closeUserRulesShellIfInactive();
|
closeUserRulesShellIfInactive();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = userRulesRenderRun;
|
const host = userRulesHost();
|
||||||
[0, 100, 350].forEach((delay) => {
|
if (!host || host._mlLoad) return;
|
||||||
setTimeout(() => {
|
host._mlLoad = renderUserRulesPage(host).catch((err) => {
|
||||||
renderUserRulesPage(run).catch((err) => console.error("[Multilang] failed to render user rules page", err));
|
console.error("[Multilang] failed to render user rules page", err);
|
||||||
}, delay);
|
if (host.isConnected) host.textContent = `Unable to load Multilang settings: ${err.message}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +310,9 @@
|
|||||||
|
|
||||||
function refreshItems(itemIds) {
|
function refreshItems(itemIds) {
|
||||||
const token = getClientToken();
|
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) => {
|
itemIds.forEach((itemId) => {
|
||||||
fetch(`${BASE_PATH}/Multilang/RefreshItem/${itemId}?includeChildren=true${token ? `&token=${encodeURIComponent(token)}` : ""}`, { method: "POST", headers });
|
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 (!url.startsWith("/")) url = "/" + url;
|
||||||
if (BASE_PATH && !url.toLowerCase().startsWith(BASE_PATH.toLowerCase() + "/")) url = BASE_PATH + 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;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +426,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => mutations.forEach((m) => {
|
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);
|
if (m.type === "attributes") rewriteElementImages(m.target);
|
||||||
}));
|
}));
|
||||||
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "src", "data-src"] });
|
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
|
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]
|
[Fact]
|
||||||
public void IsWithAndListRequiresExactSet()
|
public void IsWithAndListRequiresExactSet()
|
||||||
{
|
{
|
||||||
@@ -95,11 +113,9 @@ public sealed class CategoryRuleEvaluatorTests
|
|||||||
{
|
{
|
||||||
Id = rule.Id,
|
Id = rule.Id,
|
||||||
Label = rule.Label,
|
Label = rule.Label,
|
||||||
CriteriaText = rule.CriteriaText,
|
|
||||||
Requirements = rule.Requirements,
|
Requirements = rule.Requirements,
|
||||||
MatchAllConditions = matchAll,
|
MatchAllConditions = matchAll,
|
||||||
Scopes = rule.Scopes,
|
Scopes = rule.Scopes,
|
||||||
FieldActions = rule.FieldActions,
|
|
||||||
FieldActionLists = rule.FieldActionLists
|
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(
|
var result = ItemsProxyRequestBuilder.Build(
|
||||||
request,
|
request,
|
||||||
"/Items?Recursive=true&api_key=old",
|
"/Items?Recursive=true&api_key=old",
|
||||||
"token",
|
|
||||||
UserId,
|
UserId,
|
||||||
multilangEnabled: true);
|
multilangEnabled: true);
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath);
|
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=old", result.Upstream.Query);
|
||||||
|
Assert.DoesNotContain("api_key", result.Upstream.Query, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.DoesNotContain("api_key", result.NormalizedUrlForCache, StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("api_key", result.NormalizedUrlForCache, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("userId=" + UserId, result.NormalizedUrlForCache);
|
Assert.Contains("userId=" + UserId, result.NormalizedUrlForCache);
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,6 @@ public sealed class ItemsProxyRequestBuilderTests
|
|||||||
var result = ItemsProxyRequestBuilder.Build(
|
var result = ItemsProxyRequestBuilder.Build(
|
||||||
request,
|
request,
|
||||||
$"/Users/{OtherUserId}/Items?Limit=5",
|
$"/Users/{OtherUserId}/Items?Limit=5",
|
||||||
"token",
|
|
||||||
UserId,
|
UserId,
|
||||||
multilangEnabled: true);
|
multilangEnabled: true);
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ public sealed class ItemsProxyRequestBuilderTests
|
|||||||
var result = ItemsProxyRequestBuilder.Build(
|
var result = ItemsProxyRequestBuilder.Build(
|
||||||
request,
|
request,
|
||||||
"/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L",
|
"/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L",
|
||||||
"token",
|
|
||||||
UserId,
|
UserId,
|
||||||
multilangEnabled: true);
|
multilangEnabled: true);
|
||||||
|
|
||||||
@@ -65,7 +62,29 @@ public sealed class ItemsProxyRequestBuilderTests
|
|||||||
Assert.Equal("fi-FI", result.Controls.ClientLocale);
|
Assert.Equal("fi-FI", result.Controls.ClientLocale);
|
||||||
Assert.DoesNotContain("SortBy=", result.Upstream.Query);
|
Assert.DoesNotContain("SortBy=", result.Upstream.Query);
|
||||||
Assert.DoesNotContain("Limit=", 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]
|
[Fact]
|
||||||
@@ -76,7 +95,6 @@ public sealed class ItemsProxyRequestBuilderTests
|
|||||||
var result = ItemsProxyRequestBuilder.Build(
|
var result = ItemsProxyRequestBuilder.Build(
|
||||||
request,
|
request,
|
||||||
"/Items?SortBy=SortName&Limit=10",
|
"/Items?SortBy=SortName&Limit=10",
|
||||||
"token",
|
|
||||||
UserId,
|
UserId,
|
||||||
multilangEnabled: false);
|
multilangEnabled: false);
|
||||||
|
|
||||||
@@ -95,7 +113,6 @@ public sealed class ItemsProxyRequestBuilderTests
|
|||||||
var result = ItemsProxyRequestBuilder.Build(
|
var result = ItemsProxyRequestBuilder.Build(
|
||||||
request,
|
request,
|
||||||
"http://other-host:8096/Items",
|
"http://other-host:8096/Items",
|
||||||
"token",
|
|
||||||
UserId,
|
UserId,
|
||||||
multilangEnabled: true);
|
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"));
|
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 = "")
|
private static HttpRequest HttpRequest(string query = "")
|
||||||
{
|
{
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
@@ -116,4 +145,40 @@ public sealed class ItemsProxyRequestBuilderTests
|
|||||||
context.Request.QueryString = new QueryString(query);
|
context.Request.QueryString = new QueryString(query);
|
||||||
return context.Request;
|
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
|
public sealed class ItemsProxySortingTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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");
|
var articles = ItemsProxySorting.GetSortArticles(new SortArticleCatalog(), [], "sv-FI");
|
||||||
|
|
||||||
@@ -27,7 +35,7 @@ public sealed class ItemsProxySortingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetSortArticlesPrefersConfiguredExactLocale()
|
public void GetSortArticlesPrefersConfiguredExactLanguage()
|
||||||
{
|
{
|
||||||
var articles = ItemsProxySorting.GetSortArticles(
|
var articles = ItemsProxySorting.GetSortArticles(
|
||||||
new SortArticleCatalog(),
|
new SortArticleCatalog(),
|
||||||
@@ -40,6 +48,32 @@ public sealed class ItemsProxySortingTests
|
|||||||
Assert.Equal(["exact"], articles);
|
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]
|
[Fact]
|
||||||
public void ApplySortsByTitleAfterLeadingArticles()
|
public void ApplySortsByTitleAfterLeadingArticles()
|
||||||
{
|
{
|
||||||
@@ -54,6 +88,41 @@ public sealed class ItemsProxySortingTests
|
|||||||
Assert.Equal(["Avatar", "A Beautiful Mind", "The Matrix"], ItemNames(root));
|
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]
|
[Fact]
|
||||||
public void ApplyPadsNumbersDuringTitleSort()
|
public void ApplyPadsNumbersDuringTitleSort()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ public sealed class ItemsProxyTransformerResolutionTests
|
|||||||
private const string OriginalAction = "Original";
|
private const string OriginalAction = "Original";
|
||||||
private const string LanguagePrefix = "Language:";
|
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]
|
[Fact]
|
||||||
public void ResolveFieldUsesFirstLanguageWithData()
|
public void ResolveFieldUsesFirstLanguageWithData()
|
||||||
{
|
{
|
||||||
@@ -29,6 +45,7 @@ public sealed class ItemsProxyTransformerResolutionTests
|
|||||||
|
|
||||||
Assert.True(result.Change);
|
Assert.True(result.Change);
|
||||||
Assert.Equal("Finnish overview", result.Value);
|
Assert.Equal("Finnish overview", result.Value);
|
||||||
|
Assert.Equal("fi", result.Language);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -37,11 +54,12 @@ public sealed class ItemsProxyTransformerResolutionTests
|
|||||||
var result = ItemsProxyTransformer.ResolveField(
|
var result = ItemsProxyTransformer.ResolveField(
|
||||||
TitleField,
|
TitleField,
|
||||||
[OriginalAction, LanguagePrefix + "en"],
|
[OriginalAction, LanguagePrefix + "en"],
|
||||||
Facts(originalTitle: "Original title"),
|
Facts(originalTitle: "Original title", originalLanguage: "en"),
|
||||||
TextTranslations(("en", TitleField, "English title")));
|
TextTranslations(("en", TitleField, "English title")));
|
||||||
|
|
||||||
Assert.True(result.Change);
|
Assert.True(result.Change);
|
||||||
Assert.Equal("Original title", result.Value);
|
Assert.Equal("Original title", result.Value);
|
||||||
|
Assert.Equal("en", result.Language);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -55,6 +73,7 @@ public sealed class ItemsProxyTransformerResolutionTests
|
|||||||
|
|
||||||
Assert.False(result.Change);
|
Assert.False(result.Change);
|
||||||
Assert.Null(result.Value);
|
Assert.Null(result.Value);
|
||||||
|
Assert.Null(result.Language);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -145,13 +164,13 @@ public sealed class ItemsProxyTransformerResolutionTests
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FactsData Facts(string originalTitle = "")
|
private static FactsData Facts(string originalTitle = "", string originalLanguage = "")
|
||||||
=> new(
|
=> new(
|
||||||
"item",
|
"item",
|
||||||
"tmdb",
|
"tmdb",
|
||||||
"movie",
|
"movie",
|
||||||
originalTitle,
|
originalTitle,
|
||||||
"",
|
originalLanguage,
|
||||||
"",
|
"",
|
||||||
"[]",
|
"[]",
|
||||||
"[]",
|
"[]",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<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="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.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.Data;
|
||||||
using Jellyfin.Plugin.Multilang.Services.Providers;
|
using Jellyfin.Plugin.Multilang.Services.Providers;
|
||||||
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
||||||
using MediaBrowser.Common.Configuration;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Multilang.Tests;
|
namespace Jellyfin.Plugin.Multilang.Tests;
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ public sealed class TranslationStoreImportTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ImportTranslationDatabaseKeepsOnlyLiveItemsAndAllowedLanguages()
|
public void ImportTranslationDatabaseKeepsOnlyLiveItemsAndAllowedLanguages()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "multilang-test-" + Guid.NewGuid().ToString("N"));
|
var root = TestPaths.CreateRoot();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var source = new TranslationStore(new TestApplicationPaths(Path.Combine(root, "source")));
|
var source = new TranslationStore(new TestApplicationPaths(Path.Combine(root, "source")));
|
||||||
@@ -38,8 +37,7 @@ public sealed class TranslationStoreImportTests
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (Directory.Exists(root))
|
TestPaths.DeleteRoot(root);
|
||||||
Directory.Delete(root, recursive: true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,31 +67,4 @@ public sealed class TranslationStoreImportTests
|
|||||||
.GetValueOrDefault(itemId)?
|
.GetValueOrDefault(itemId)?
|
||||||
.GetValueOrDefault(lang)?
|
.GetValueOrDefault(lang)?
|
||||||
.GetValueOrDefault(field);
|
.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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+72
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user