Expand integration coverage and fix settings UI
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -294,9 +294,7 @@ public sealed class MultilangController : ControllerBase
|
|||||||
var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes));
|
var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes));
|
||||||
if (!precache && cacheAllowed)
|
if (!precache && cacheAllowed)
|
||||||
{
|
{
|
||||||
var baseUri = new Uri($"{Request.Scheme}://{Request.Host}{Request.PathBase}/");
|
|
||||||
_itemsProxyPrecacheService.ObserveUserActivity(
|
_itemsProxyPrecacheService.ObserveUserActivity(
|
||||||
baseUri,
|
|
||||||
userId,
|
userId,
|
||||||
token,
|
token,
|
||||||
proxyRequest.Controls.ClientLocale,
|
proxyRequest.Controls.ClientLocale,
|
||||||
|
|||||||
@@ -259,7 +259,7 @@
|
|||||||
|
|
||||||
function renderJellyfinTitleLanguages() {
|
function renderJellyfinTitleLanguages() {
|
||||||
var select = document.getElementById("ml-jellyfin-title-language");
|
var select = document.getElementById("ml-jellyfin-title-language");
|
||||||
var selected = state.config.JellyfinTitleLanguageFallback || state.config.jellyfinTitleLanguageFallback || "";
|
var selected = M.prop(state.config, "JellyfinTitleLanguageFallback", "jellyfinTitleLanguageFallback", "");
|
||||||
select.replaceChildren();
|
select.replaceChildren();
|
||||||
var defaultOption = make("option", "", "Jellyfin's default");
|
var defaultOption = make("option", "", "Jellyfin's default");
|
||||||
defaultOption.value = "";
|
defaultOption.value = "";
|
||||||
@@ -268,9 +268,9 @@
|
|||||||
separator.disabled = true;
|
separator.disabled = true;
|
||||||
select.appendChild(separator);
|
select.appendChild(separator);
|
||||||
state.metadataLanguages.forEach(function (language) {
|
state.metadataLanguages.forEach(function (language) {
|
||||||
var name = String(language.Name || language.name || "");
|
var name = String(M.prop(language, "Name", "name", ""));
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
var option = make("option", "", String(language.DisplayName || language.displayName || name) + " (" + name + ")");
|
var option = make("option", "", String(M.prop(language, "DisplayName", "displayName", name)) + " (" + name + ")");
|
||||||
option.value = name;
|
option.value = name;
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
@@ -539,13 +539,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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",
|
||||||
makeAgeChip(entry.AgeSeconds || entry.ageSeconds || 0)
|
makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0))
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -574,21 +574,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
var url = entry.Url || entry.url || "";
|
var url = M.prop(entry, "Url", "url", "");
|
||||||
var cacheState = (entry.CacheHit || entry.cacheHit)
|
var cacheState = M.prop(entry, "CacheHit", "cacheHit", false)
|
||||||
? "cache hit"
|
? "cache hit"
|
||||||
: ((entry.InFlightCoalesced || entry.inFlightCoalesced)
|
: (M.prop(entry, "InFlightCoalesced", "inFlightCoalesced", false)
|
||||||
? "in-flight hit"
|
? "in-flight hit"
|
||||||
: ((entry.CacheStored || entry.cacheStored) ? "cache miss: cached" : "cache miss"));
|
: (M.prop(entry, "CacheStored", "cacheStored", false) ? "cache miss: cached" : "cache miss"));
|
||||||
root.appendChild(makeDiagnosticRow(url, [
|
root.appendChild(makeDiagnosticRow(url, [
|
||||||
cacheState,
|
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",
|
||||||
makeAgeChip(entry.AgeSeconds || entry.ageSeconds || 0)
|
makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0))
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1260,7 +1260,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
$("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-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); });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,13 +163,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));
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Multilang.Services;
|
namespace Jellyfin.Plugin.Multilang.Services;
|
||||||
@@ -6,20 +9,27 @@ namespace Jellyfin.Plugin.Multilang.Services;
|
|||||||
public sealed class ItemsProxyPrecacheService
|
public sealed class ItemsProxyPrecacheService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan IdleThreshold = TimeSpan.FromMinutes(30);
|
private static readonly TimeSpan IdleThreshold = TimeSpan.FromMinutes(30);
|
||||||
|
private readonly IServerApplicationHost _applicationHost;
|
||||||
|
private readonly IConfigurationManager _configurationManager;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ILogger<ItemsProxyPrecacheService> _logger;
|
private readonly ILogger<ItemsProxyPrecacheService> _logger;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private readonly Dictionary<string, DateTimeOffset> _lastSeen = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, DateTimeOffset> _lastSeen = new(StringComparer.Ordinal);
|
||||||
private readonly HashSet<string> _pendingUsers = new(StringComparer.Ordinal);
|
private readonly HashSet<string> _pendingUsers = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
public ItemsProxyPrecacheService(IHttpClientFactory httpClientFactory, ILogger<ItemsProxyPrecacheService> logger)
|
public ItemsProxyPrecacheService(
|
||||||
|
IServerApplicationHost applicationHost,
|
||||||
|
IConfigurationManager configurationManager,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<ItemsProxyPrecacheService> logger)
|
||||||
{
|
{
|
||||||
|
_applicationHost = applicationHost;
|
||||||
|
_configurationManager = configurationManager;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ObserveUserActivity(
|
public void ObserveUserActivity(
|
||||||
Uri baseUri,
|
|
||||||
string userId,
|
string userId,
|
||||||
string token,
|
string token,
|
||||||
string clientLocale,
|
string clientLocale,
|
||||||
@@ -38,11 +48,10 @@ public sealed class ItemsProxyPrecacheService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = PrecacheAsync(baseUri, userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries);
|
_ = PrecacheAsync(userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PrecacheAsync(
|
private async Task PrecacheAsync(
|
||||||
Uri baseUri,
|
|
||||||
string userId,
|
string userId,
|
||||||
string token,
|
string token,
|
||||||
string clientLocale,
|
string clientLocale,
|
||||||
@@ -51,6 +60,8 @@ public sealed class ItemsProxyPrecacheService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var port = _configurationManager.GetNetworkConfiguration().InternalHttpPort;
|
||||||
|
var baseUri = new Uri(_applicationHost.GetLocalApiUrl("127.0.0.1", "http", port).TrimEnd('/') + "/");
|
||||||
var views = await GetJsonAsync(new Uri(baseUri, $"Users/{userId}/Views"), token).ConfigureAwait(false);
|
var views = await GetJsonAsync(new Uri(baseUri, $"Users/{userId}/Views"), token).ConfigureAwait(false);
|
||||||
var items = views["Items"]?.AsArray() ?? [];
|
var items = views["Items"]?.AsArray() ?? [];
|
||||||
var targets = items
|
var targets = items
|
||||||
|
|||||||
@@ -82,26 +82,25 @@ public static class ItemsProxySorting
|
|||||||
|
|
||||||
public static string[] GetSortArticles(
|
public static string[] GetSortArticles(
|
||||||
SortArticleCatalog articleCatalog,
|
SortArticleCatalog articleCatalog,
|
||||||
IEnumerable<SortArticleEntry> configuredArticles,
|
IReadOnlyList<SortArticleEntry> configuredArticles,
|
||||||
string? titleLanguage,
|
string? titleLanguage,
|
||||||
bool ignoreArticles = true)
|
bool ignoreArticles = true)
|
||||||
{
|
{
|
||||||
if (!ignoreArticles)
|
if (!ignoreArticles)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var configured = configuredArticles.ToArray();
|
var always = configuredArticles
|
||||||
var always = configured
|
|
||||||
.Where(e => e.AlwaysApply == true)
|
.Where(e => e.AlwaysApply == true)
|
||||||
.SelectMany(e => SplitArticles(e.Articles));
|
.SelectMany(e => SplitArticles(e.Articles));
|
||||||
return always
|
return always
|
||||||
.Concat(GetLanguageArticles(articleCatalog, configured, titleLanguage))
|
.Concat(GetLanguageArticles(articleCatalog, configuredArticles, titleLanguage))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] GetLanguageArticles(
|
private static string[] GetLanguageArticles(
|
||||||
SortArticleCatalog articleCatalog,
|
SortArticleCatalog articleCatalog,
|
||||||
SortArticleEntry[] configured,
|
IReadOnlyList<SortArticleEntry> configured,
|
||||||
string? titleLanguage)
|
string? titleLanguage)
|
||||||
{
|
{
|
||||||
var language = (titleLanguage ?? string.Empty).Trim();
|
var language = (titleLanguage ?? string.Empty).Trim();
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ public sealed class ItemsProxyTransformer
|
|||||||
? ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem)
|
? ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem)
|
||||||
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var jellyfinTitleLanguages = 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, item =>
|
ItemsProxySorting.Apply(root, controls, sortCulture, item =>
|
||||||
{
|
{
|
||||||
@@ -91,7 +92,13 @@ public sealed class ItemsProxyTransformer
|
|||||||
var language = itemId is not null && titleLanguages.TryGetValue(itemId, out var translatedLanguage)
|
var language = itemId is not null && titleLanguages.TryGetValue(itemId, out var translatedLanguage)
|
||||||
? translatedLanguage
|
? translatedLanguage
|
||||||
: ResolveJellyfinTitleLanguage(itemId, config, jellyfinTitleLanguages);
|
: ResolveJellyfinTitleLanguage(itemId, config, jellyfinTitleLanguages);
|
||||||
return ItemsProxySorting.GetSortArticles(_articleCatalog, config.ArticleEntries, language, config.IgnoreArticlesWhenSorting);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
event?.stopPropagation?.();
|
event?.stopPropagation?.();
|
||||||
event?.stopImmediatePropagation?.();
|
event?.stopImmediatePropagation?.();
|
||||||
|
document.querySelector("#app-user-menu .MuiBackdrop-root")?.click();
|
||||||
history.pushState({}, "", userRulesHref());
|
history.pushState({}, "", userRulesHref());
|
||||||
scheduleUserRulesPageRender();
|
scheduleUserRulesPageRender();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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", "fi", "poster", "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);
|
||||||
|
}
|
||||||
|
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", "fi", "poster", 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", "fi", "poster", "https://images.example/poster.jpg", true, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.DeleteRoot(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Jellyfin.Plugin.Multilang.Services;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Multilang.Tests;
|
||||||
|
|
||||||
|
public sealed class ItemsProxyCacheTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StoreRetrievesAndInvalidatesByItemId()
|
||||||
|
{
|
||||||
|
var cache = new ItemsProxyCache();
|
||||||
|
|
||||||
|
Assert.True(Store(cache, "key", ["A"]));
|
||||||
|
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
|
||||||
|
Assert.Equal("body-key", response.Body);
|
||||||
|
Assert.Equal(1, response.ItemCount);
|
||||||
|
|
||||||
|
cache.InvalidateItems(["a"]);
|
||||||
|
|
||||||
|
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MicroCacheServesFastResponsesAndIsInvalidatedWithTheMainCache()
|
||||||
|
{
|
||||||
|
var cache = new ItemsProxyCache();
|
||||||
|
|
||||||
|
Assert.True(cache.StoreMicro("key", "user", "/Items", ["A"], "micro", "application/json", 1, 1024));
|
||||||
|
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
|
||||||
|
Assert.Equal("micro", response.Body);
|
||||||
|
|
||||||
|
cache.InvalidateItems(["A"]);
|
||||||
|
|
||||||
|
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StoreEvictsTheOldestEntryWhenCapacityIsExceeded()
|
||||||
|
{
|
||||||
|
var cache = new ItemsProxyCache();
|
||||||
|
|
||||||
|
Assert.True(Store(cache, "first", ["A"], body: "1111", maxBytes: 6));
|
||||||
|
Thread.Sleep(10);
|
||||||
|
Assert.True(Store(cache, "second", ["B"], 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", ["A"], 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", ["A"], body: "12345", maxBytes: 4));
|
||||||
|
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Store(
|
||||||
|
ItemsProxyCache cache,
|
||||||
|
string key,
|
||||||
|
string[] itemIds,
|
||||||
|
string? body = null,
|
||||||
|
long maxBytes = 1024,
|
||||||
|
TimeSpan? ttl = null)
|
||||||
|
=> cache.Store(
|
||||||
|
key,
|
||||||
|
"user",
|
||||||
|
"/Items",
|
||||||
|
itemIds,
|
||||||
|
body ?? "body-" + key,
|
||||||
|
"application/json",
|
||||||
|
100,
|
||||||
|
maxBytes,
|
||||||
|
ttl ?? TimeSpan.FromMinutes(1));
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ public sealed class ItemsProxyRequestCoalescerTests
|
|||||||
var response = new TaskCompletionSource<ItemsProxyUpstreamResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var response = new TaskCompletionSource<ItemsProxyUpstreamResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var fetches = 0;
|
var fetches = 0;
|
||||||
|
|
||||||
Task<ItemsProxyUpstreamResponse> Fetch(CancellationToken _)
|
Task<ItemsProxyUpstreamResponse> Fetch(CancellationToken _)
|
||||||
{
|
{
|
||||||
fetches++;
|
fetches++;
|
||||||
return response.Task;
|
return response.Task;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
<PackageReference Include="Jellyfin.Common" Version="12.0.0-rc2" />
|
<PackageReference Include="Jellyfin.Common" Version="12.0.0-rc2" />
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="12.0.0-rc2" />
|
||||||
<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,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,85 @@
|
|||||||
|
using Jellyfin.Plugin.Multilang.Services;
|
||||||
|
using Jellyfin.Plugin.Multilang.Services.Refresh;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Multilang.Tests;
|
||||||
|
|
||||||
|
public sealed class RefreshCoordinatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task QueuedItemsUseSourceTierThenWorkClassPriority()
|
||||||
|
{
|
||||||
|
var coordinator = new RefreshCoordinator(new ItemsProxyCache());
|
||||||
|
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 DuplicateQueuedItemUsesTheHighestJobAndTierAndInvalidatesCache()
|
||||||
|
{
|
||||||
|
var cache = new ItemsProxyCache();
|
||||||
|
Assert.True(cache.Store("target", "user", "/Items", ["target"], "body", "application/json", 1, 1024, TimeSpan.FromMinutes(1)));
|
||||||
|
var coordinator = new RefreshCoordinator(cache);
|
||||||
|
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.False(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,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: 2), 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,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,218 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows configured translations and uses the injected ItemsProxy route', async ({ page, request }) => {
|
||||||
|
const proxiedRequests = [];
|
||||||
|
page.on('request', request => {
|
||||||
|
if (new URL(request.url()).pathname === '/Multilang/ItemsProxy') {
|
||||||
|
proxiedRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await signIn(page);
|
||||||
|
await openFixtureLibrary(page, fixture.translatedTitles[0]);
|
||||||
|
|
||||||
|
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: [{
|
||||||
|
Field: 'original_language',
|
||||||
|
Fields: ['original_language'],
|
||||||
|
Relation: 'is',
|
||||||
|
Values: ['sv'],
|
||||||
|
UseOr: false,
|
||||||
|
UseFieldOr: false
|
||||||
|
}],
|
||||||
|
MatchAllConditions: true,
|
||||||
|
Scopes: ['M'],
|
||||||
|
FieldActionLists: categoryActions,
|
||||||
|
FieldActions: {}
|
||||||
|
}];
|
||||||
|
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('saves a structured classification rule through the user settings page', async ({ page, request }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await page.goto(`/web/#/mypreferences?userId=${fixture.userId}`);
|
||||||
|
await page.getByRole('button', { name: 'User Menu' }).click();
|
||||||
|
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']
|
||||||
|
})]);
|
||||||
|
});
|
||||||
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,267 @@
|
|||||||
|
#!/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()))
|
||||||
|
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