Expand integration coverage and fix settings UI

This commit is contained in:
ajp_anton
2026-07-21 00:42:44 +00:00
parent 25088ce91d
commit 094d5ad9f0
26 changed files with 1665 additions and 66 deletions
+7
View File
@@ -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));
}
@@ -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)
{
}
}
} }
+34
View File
@@ -0,0 +1,34 @@
# Browser Tests
These JF12-only integration tests create three temporary NFO movie fixtures,
seed deterministic Multilang data for their real Jellyfin IDs, and exercise
the ordinary web UI with Chromium. They cover proxy translation/fallback and
ordering, genre translation, per-user enablement, pre-cache population,
category-rule precedence, and both settings pages. The runner restores the
complete JF12 `config`, `data`, and `cache` volumes afterward. It refuses any target except
`jellyfin12` on port `8097`.
Install the ignored local dependencies once:
```bash
cd tests/browser
npm install
npx playwright install chromium
```
Run from the repository root with JF12 test-account credentials:
```bash
python3 tools/run_jellyfin12_browser_tests.py \
--base-url http://server.sedomain:8097 \
--username "$JELLYFIN_TEST_USER" \
--password "$JELLYFIN_TEST_PASSWORD" \
--ssh-host server.sedomain \
--state-root /mnt/apps-pool/Docker/Volumes/jellyfin12 \
--confirm-destructive
```
`node_modules`, browser artifacts, credentials, test results, and traces are
ignored. A failed run still restores the server state in a `finally` block; a
restoration hash mismatch intentionally leaves its remote snapshot in `/tmp`
for investigation.
+218
View File
@@ -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']
})]);
});
+72
View File
@@ -0,0 +1,72 @@
{
"name": "jellyfin-multilang-browser-tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jellyfin-multilang-browser-tests",
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "jellyfin-multilang-browser-tests",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "1.61.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: '.',
testMatch: 'multilang.spec.js',
fullyParallel: false,
retries: 0,
reporter: 'list',
use: {
baseURL: process.env.JELLYFIN_BASE_URL,
screenshot: 'only-on-failure',
trace: 'retain-on-failure'
}
});
+267
View File
@@ -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())
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env python3
"""Destructive Multilang integration checks with verified JF12 state restoration.
The script intentionally accepts all deployment details as arguments. It refuses
to run unless both the container and persistent state path are the JF12 test
instance. It snapshots only Multilang-owned state and the injected web index,
then restores and hashes those paths in a finally block. Run it from a separate
machine through SSH, or use --local-docker when it runs on the Docker host.
"""
from __future__ import annotations
import argparse
import json
import shlex
import subprocess
import sys
import time
import uuid
import zipfile
from dataclasses import dataclass
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
PLUGIN_CONFIG = "config/plugins/configurations/Jellyfin.Plugin.Multilang.xml"
PLUGIN_DATA = "data/multilang"
WEB_INDEX = "/jellyfin/jellyfin-web/index.html"
@dataclass(frozen=True)
class Response:
status: int
body: bytes
content_type: str
class JellyfinApi:
def __init__(self, base_url: str, token: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
def request(self, method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None) -> Response:
request_headers = dict(headers or {})
if self.token:
request_headers.setdefault("Authorization", f'MediaBrowser Token="{self.token}"')
request_headers.setdefault("X-Emby-Token", self.token)
request_headers.setdefault("X-MediaBrowser-Token", self.token)
request = Request(self.base_url + path, body, request_headers, method=method)
try:
with urlopen(request, timeout=60) as response:
return Response(response.status, response.read(), response.headers.get_content_type())
except HTTPError as error:
return Response(error.code, error.read(), error.headers.get_content_type())
def json(self, method: str, path: str, payload: Any | None = None) -> Any:
body = None if payload is None else json.dumps(payload).encode()
headers = {} if body is None else {"Content-Type": "application/json"}
response = self.request(method, path, body, headers)
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"{method} {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
return json.loads(response.body)
def multipart(self, path: str, fields: dict[str, str], filename: str, contents: bytes) -> Any:
boundary = "----multilang-live-test-" + uuid.uuid4().hex
chunks: list[bytes] = []
for key, value in fields.items():
chunks.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
])
chunks.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
b"Content-Type: application/zip\r\n\r\n",
contents,
b"\r\n",
f"--{boundary}--\r\n".encode(),
])
response = self.request("POST", path, b"".join(chunks), {"Content-Type": f"multipart/form-data; boundary={boundary}"})
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"POST {path} returned HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
return json.loads(response.body)
class RemoteState:
def __init__(self, args: argparse.Namespace) -> None:
self.args = args
self.snapshot_dir = f"/tmp/multilang-live-test-{uuid.uuid4().hex}"
self.expected_hashes = ""
self.expected_web_hash = ""
def run(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
if self.args.local_docker:
result = subprocess.run(["bash", "-lc", command], text=True, capture_output=True)
else:
target = f"{self.args.ssh_user}@{self.args.ssh_host}"
result = subprocess.run(["ssh", target, command], text=True, capture_output=True)
if check and result.returncode:
raise RuntimeError(f"Remote command failed: {command}\n{result.stderr.strip()}")
return result
def docker(self, command: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return self.run("docker " + command, check)
def stop(self) -> None:
self.docker("stop " + shlex.quote(self.args.container))
def start(self) -> None:
self.docker("start " + shlex.quote(self.args.container))
def verify_target(self) -> None:
mounts = self.docker("inspect -f '{{ range .Mounts }}{{ println .Source }}{{ end }}' " + shlex.quote(self.args.container)).stdout
root = self.args.state_root.rstrip("/") + "/"
mounted_paths = mounts.splitlines()
if not any(path.startswith(root) for path in mounted_paths):
raise RuntimeError("The requested JF12 state root does not contain any mounts for the requested container.")
for required in ("config", "data"):
if root + required not in mounted_paths:
raise RuntimeError(f"The requested JF12 state root does not mount {required} into the requested container.")
if self.args.full_server_state and root + "cache" not in mounted_paths:
raise RuntimeError("Full-state verification requires the JF12 cache mount.")
def state_paths(self) -> tuple[str, ...]:
return ("config", "data", "cache") if self.args.full_server_state else (PLUGIN_DATA, PLUGIN_CONFIG)
def hash_plugin_state(self) -> str:
root = shlex.quote(self.args.state_root)
paths = " ".join(shlex.quote(path) for path in self.state_paths())
command = (
f"cd {root} && "
f"find {paths} -type f -print0 2>/dev/null "
"| sort -z | xargs -0 -r sha256sum"
)
return self.run(command).stdout
def snapshot(self) -> None:
self.stop()
try:
root = shlex.quote(self.args.state_root)
snapshot = shlex.quote(self.snapshot_dir)
paths = " ".join(shlex.quote(path) for path in self.state_paths())
self.run(f"mkdir -p {snapshot}")
self.expected_hashes = self.hash_plugin_state()
self.run(
f"tar -C {root} --ignore-failed-read -czf {snapshot}/plugin-state.tar.gz {paths}"
)
self.docker(
"cp " + shlex.quote(self.args.container + ":" + WEB_INDEX) + " " + shlex.quote(self.snapshot_dir + "/index.html")
)
self.expected_web_hash = self.run("sha256sum " + shlex.quote(self.snapshot_dir + "/index.html")).stdout.split()[0]
finally:
self.start()
def restore_and_verify(self) -> None:
self.stop()
try:
root = shlex.quote(self.args.state_root)
snapshot = shlex.quote(self.snapshot_dir)
removals = " ".join(f"{root}/{shlex.quote(path)}" for path in self.state_paths())
self.run(
f"rm -rf {removals} && "
f"tar -C {root} -xzf {snapshot}/plugin-state.tar.gz"
)
self.docker(
"cp " + shlex.quote(self.snapshot_dir + "/index.html") + " " + shlex.quote(self.args.container + ":" + WEB_INDEX)
)
actual_hashes = self.hash_plugin_state()
restored_index = self.snapshot_dir + "/restored-index.html"
self.docker(
"cp " + shlex.quote(self.args.container + ":" + WEB_INDEX) + " " + shlex.quote(restored_index)
)
actual_web_hash = self.run("sha256sum " + shlex.quote(restored_index)).stdout.split()[0]
if actual_hashes != self.expected_hashes or actual_web_hash != self.expected_web_hash:
raise RuntimeError(
"State restoration hash mismatch. The remote snapshot was retained at " + self.snapshot_dir
)
finally:
self.start()
def discard_snapshot(self) -> None:
self.run("rm -rf " + shlex.quote(self.snapshot_dir))
def login(base_url: str, username: str, password: str) -> tuple[JellyfinApi, str]:
api = JellyfinApi(base_url)
headers = {
"Content-Type": "application/json",
"Authorization": 'MediaBrowser Client="Multilang live tests", Device="Codex", DeviceId="multilang-live-tests", Version="1.0"',
}
response = api.request("POST", "/Users/AuthenticateByName", json.dumps({"Username": username, "Pw": password}).encode(), headers)
if response.status != 200:
raise RuntimeError(f"Authentication failed: HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
payload = json.loads(response.body)
return JellyfinApi(base_url, payload["AccessToken"]), payload["User"]["Id"]
def wait_for_login(base_url: str, username: str, password: str) -> tuple[JellyfinApi, str]:
for _ in range(60):
try:
return login(base_url, username, password)
except (OSError, URLError):
time.sleep(1)
continue
except RuntimeError as error:
if "HTTP 5" not in str(error):
raise
time.sleep(1)
raise RuntimeError("Jellyfin12 did not accept authentication within 60 seconds.")
def assert_equal(actual: Any, expected: Any, name: str) -> None:
if actual != expected:
raise AssertionError(f"{name}: expected {expected!r}, got {actual!r}")
def export_zip(api: JellyfinApi, query: dict[str, str]) -> bytes:
response = api.request("GET", "/Multilang/Export?" + urlencode(query))
if response.status != 200:
raise RuntimeError(f"Export failed with HTTP {response.status}: {response.body[:500].decode(errors='replace')}")
with zipfile.ZipFile(BytesIO(response.body)) as archive:
if "manifest.json" not in archive.namelist():
raise AssertionError("Admin export did not contain manifest.json.")
return response.body
def choose_library(api: JellyfinApi, user_id: str) -> tuple[str, str]:
views = api.json("GET", f"/Users/{user_id}/Views")
for view in views.get("Items", []):
collection_type = str(view.get("CollectionType", "")).lower()
if collection_type in {"movies", "tvshows"}:
return view["Id"], "Movie" if collection_type == "movies" else "Series"
raise RuntimeError("No movie or TV-show library was available for the live proxy test.")
def proxy_path(user_id: str, library_id: str, item_type: str) -> str:
upstream = (
f"/Users/{user_id}/Items?ParentId={library_id}&IncludeItemTypes={item_type}"
"&Recursive=true&SortBy=SortName&SortOrder=Ascending&StartIndex=0&Limit=100"
)
return "/Multilang/ItemsProxy?" + urlencode({"url": upstream, "mlLocale": "en-US"})
def run_checks(api: JellyfinApi, user_id: str, include_refresh: bool) -> None:
print("[1/7] Checking configuration and user-rule persistence")
config = api.json("GET", "/Multilang/AdminConfig")
rules = api.json("GET", "/Multilang/UserRules/self")
baseline_export = export_zip(api, {
"pluginSettings": "true",
"userSettings": "true",
"translationsDatabase": "false",
"downloadedAssets": "false",
})
changed_config = dict(config)
changed_config["ItemsProxyCacheThresholdMs"] = 0 if config.get("ItemsProxyCacheThresholdMs") else 1
saved_config = api.json("POST", "/Multilang/AdminConfig", changed_config)
assert_equal(saved_config["ItemsProxyCacheThresholdMs"], changed_config["ItemsProxyCacheThresholdMs"], "admin configuration save")
changed_rules = dict(rules)
changed_rules["Enabled"] = True
changed_rules["SortLocale"] = "fi-FI"
saved_rules = api.json("POST", "/Multilang/UserRules/self", changed_rules)
assert_equal(saved_rules["Enabled"], True, "user rule enable")
assert_equal(saved_rules["SortLocale"], "fi-FI", "user sort locale save")
print("[2/7] Checking user and admin backup/import endpoints")
user_export = api.request("GET", "/Multilang/UserRules/self/export")
if user_export.status != 200:
raise RuntimeError(f"User export failed with HTTP {user_export.status}")
inspected = api.multipart("/Multilang/UserRules/self/import/inspect", {}, "user-rules.zip", user_export.body)
if not inspected.get("ContainsCurrentUser"):
raise AssertionError("User export inspection did not identify the exporting user.")
imported_user = api.multipart("/Multilang/UserRules/self/import", {}, "user-rules.zip", user_export.body)
if imported_user.get("UserSettingsImported") != 1:
raise AssertionError("User export did not import exactly one rule set.")
api.multipart(
"/Multilang/Import",
{"pluginSettings": "true", "userSettings": "true", "translationsDatabase": "false", "downloadedAssets": "false"},
"admin-export.zip",
baseline_export,
)
restored_config = api.json("GET", "/Multilang/AdminConfig")
assert_equal(restored_config["ItemsProxyCacheThresholdMs"], config["ItemsProxyCacheThresholdMs"], "admin export/import round trip")
print("[3/7] Checking transformed proxy responses, cache entries, and pre-cache triggering")
enabled_rules = dict(rules)
enabled_rules["Enabled"] = True
api.json("POST", "/Multilang/UserRules/self", enabled_rules)
cached_config = dict(config)
cached_config["ItemsProxyCacheThresholdMs"] = 0
cached_config["ItemsProxyCacheTtlMinutes"] = max(1, int(cached_config.get("ItemsProxyCacheTtlMinutes", 1)))
cached_config["ItemsProxyCacheMaxMiB"] = max(1, int(cached_config.get("ItemsProxyCacheMaxMiB", 1)))
api.json("POST", "/Multilang/AdminConfig", cached_config)
api.json("POST", "/Multilang/ClearCache")
library_id, item_type = choose_library(api, user_id)
path = proxy_path(user_id, library_id, item_type)
first = api.request("GET", path)
second = api.request("GET", path)
if first.status != 200 or second.status != 200:
raise RuntimeError(f"ItemsProxy returned {first.status} then {second.status}")
first_payload = json.loads(first.body)
if not isinstance(first_payload.get("Items"), list):
raise AssertionError("ItemsProxy response did not contain an Items list.")
if not first_payload["Items"]:
raise RuntimeError("The selected library had no items for the cleanup probe.")
probe_item_id = first_payload["Items"][0]["Id"]
requests = api.json("GET", "/Multilang/ItemsProxyRequests")
if not any(request.get("CacheHit") for request in requests):
raise AssertionError("Second proxy request did not produce a cache hit.")
entries = api.json("GET", "/Multilang/CacheEntries")
if not entries:
raise AssertionError("ItemsProxy did not store a cache entry at a zero-millisecond threshold.")
print("[4/7] Checking concurrent proxy requests remain valid")
api.json("POST", "/Multilang/ClearCache")
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda _: api.request("GET", path), range(2)))
if any(response.status != 200 for response in responses):
raise AssertionError("Concurrent proxy request did not return HTTP 200.")
if include_refresh:
print("[5/7] Checking one-item refresh and debug data")
refreshed = api.json("POST", f"/Multilang/RefreshItem/{probe_item_id}?includeChildren=false")
assert_equal(refreshed["ItemId"].lower(), probe_item_id.replace("-", "").lower(), "single-item refresh")
debug = api.json("GET", f"/Multilang/Debug/{probe_item_id}")
assert_equal(debug["ItemId"].lower(), probe_item_id.replace("-", "").lower(), "debug item identity")
else:
print("[5/7] Skipping the provider-backed refresh probe (pass --include-refresh to run it)")
print("[6/7] Checking cleanup endpoint and default-state recovery")
api.json("POST", "/Multilang/CleanupAll", {"Confirm": True})
cleanup_info = api.json("GET", "/Multilang/ExportInfo")
if cleanup_info["AssetsBytes"] != 0:
raise AssertionError("CleanupAll did not remove local assets.")
default_rules = api.json("GET", "/Multilang/UserRules/self")
if default_rules.get("Enabled"):
raise AssertionError("CleanupAll did not remove the user rule document.")
if api.json("GET", f"/Multilang/Debug/{probe_item_id}").get("Facts") is not None:
raise AssertionError("CleanupAll did not remove stored item facts.")
print("[7/7] Live checks passed; raw restoration will now run")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", required=True, help="JF12 base URL, expected to use port 8097")
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--ssh-host", required=True)
parser.add_argument("--ssh-user", default="root")
parser.add_argument("--local-docker", action="store_true", help="Run Docker and filesystem commands on this host instead of through SSH")
parser.add_argument("--container", default="jellyfin12")
parser.add_argument("--state-root", required=True, help="JF12 persistent volume root")
parser.add_argument("--confirm-destructive", action="store_true", help="Required: tests modify JF12 Multilang state before restoring it")
parser.add_argument("--include-refresh", action="store_true", help="Also run a provider-backed single-item refresh; this can take longer than the core suite")
parser.add_argument("--full-server-state", action="store_true", help="Snapshot and restore the JF12 config, data, and cache mounts instead of only Multilang-owned paths")
args = parser.parse_args()
parsed = urlparse(args.base_url)
if not args.confirm_destructive:
parser.error("--confirm-destructive is required")
if args.container != "jellyfin12":
parser.error("This runner only permits the jellyfin12 container")
if parsed.port != 8097:
parser.error("This runner only permits the JF12 test URL on port 8097")
if parsed.hostname in {"127.0.0.1", "::1", "localhost"}:
parser.error("Use a LAN host name or address reachable from inside the Jellyfin container, not loopback.")
if not args.state_root.rstrip("/").endswith("/jellyfin12"):
parser.error("This runner only permits a state root ending in /jellyfin12")
return args
def main() -> int:
args = parse_args()
remote = RemoteState(args)
success = False
try:
remote.verify_target()
remote.snapshot()
api, user_id = wait_for_login(args.base_url, args.username, args.password)
run_checks(api, user_id, args.include_refresh)
success = True
finally:
try:
if remote.expected_web_hash:
remote.restore_and_verify()
scope = "JF12 config, data, cache, and injected web index" if args.full_server_state else "Multilang state and injected web index"
print(f"Restoration hash check passed for {scope}.")
remote.discard_snapshot()
else:
remote.discard_snapshot()
except Exception as error:
print(f"RESTORATION FAILED: {error}", file=sys.stderr)
return 2
return 0 if success else 1
if __name__ == "__main__":
raise SystemExit(main())