diff --git a/.gitignore b/.gitignore index 040ea51..d58a574 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ old-attempt/ # Generated artifacts *.pyc *.sqlite + +# Local browser-test dependencies, credentials, and generated output +tests/browser/node_modules/ +tests/browser/test-results/ +tests/browser/playwright-report/ +tests/browser/.auth/ +tests/browser/jellyfin.local.json diff --git a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs index 851a45c..9572224 100644 --- a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs +++ b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs @@ -294,9 +294,7 @@ public sealed class MultilangController : ControllerBase var cacheTtl = TimeSpan.FromMinutes(Math.Max(1, cfg.ItemsProxyCacheTtlMinutes)); if (!precache && cacheAllowed) { - var baseUri = new Uri($"{Request.Scheme}://{Request.Host}{Request.PathBase}/"); _itemsProxyPrecacheService.ObserveUserActivity( - baseUri, userId, token, proxyRequest.Controls.ClientLocale, diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html index b36a2c5..1c181ee 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html +++ b/src/Jellyfin.Plugin.Multilang/Configuration/configPage.html @@ -259,7 +259,7 @@ function renderJellyfinTitleLanguages() { 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(); var defaultOption = make("option", "", "Jellyfin's default"); defaultOption.value = ""; @@ -268,9 +268,9 @@ separator.disabled = true; select.appendChild(separator); state.metadataLanguages.forEach(function (language) { - var name = String(language.Name || language.name || ""); + var name = String(M.prop(language, "Name", "name", "")); 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; select.appendChild(option); }); @@ -539,13 +539,13 @@ } entries.forEach(function (entry) { - var url = entry.Url || entry.url || ""; + var url = M.prop(entry, "Url", "url", ""); root.appendChild(makeDiagnosticRow(url, [ "cached", - (entry.ItemCount || entry.itemCount || 0) + " items", - (entry.DurationMs || entry.durationMs || 0) + " ms", - ((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB", - makeAgeChip(entry.AgeSeconds || entry.ageSeconds || 0) + M.prop(entry, "ItemCount", "itemCount", 0) + " items", + M.prop(entry, "DurationMs", "durationMs", 0) + " ms", + (M.prop(entry, "SizeBytes", "sizeBytes", 0) / 1024).toFixed(1) + " KiB", + makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0)) ])); }); } @@ -574,21 +574,21 @@ } entries.forEach(function (entry) { - var url = entry.Url || entry.url || ""; - var cacheState = (entry.CacheHit || entry.cacheHit) + var url = M.prop(entry, "Url", "url", ""); + var cacheState = M.prop(entry, "CacheHit", "cacheHit", false) ? "cache hit" - : ((entry.InFlightCoalesced || entry.inFlightCoalesced) + : (M.prop(entry, "InFlightCoalesced", "inFlightCoalesced", false) ? "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, [ cacheState, - "HTTP " + (entry.StatusCode || entry.statusCode || 0), - (entry.ItemCount || entry.itemCount || 0) + " items", - "total " + (entry.TotalMs || entry.totalMs || 0) + " ms", - "upstream " + (entry.UpstreamMs || entry.upstreamMs || 0) + " ms", - "transform " + (entry.TransformMs || entry.transformMs || 0) + " ms", - ((entry.SizeBytes || entry.sizeBytes || 0) / 1024).toFixed(1) + " KiB", - makeAgeChip(entry.AgeSeconds || entry.ageSeconds || 0) + "HTTP " + M.prop(entry, "StatusCode", "statusCode", 0), + M.prop(entry, "ItemCount", "itemCount", 0) + " items", + "total " + M.prop(entry, "TotalMs", "totalMs", 0) + " ms", + "upstream " + M.prop(entry, "UpstreamMs", "upstreamMs", 0) + " ms", + "transform " + M.prop(entry, "TransformMs", "transformMs", 0) + " ms", + (M.prop(entry, "SizeBytes", "sizeBytes", 0) / 1024).toFixed(1) + " KiB", + makeAgeChip(M.prop(entry, "AgeSeconds", "ageSeconds", 0)) ])); }); } diff --git a/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html b/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html index d93a651..6206164 100644 --- a/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html +++ b/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html @@ -1260,7 +1260,10 @@ } }); $("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 () { downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); }); }); diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs index 09daaee..20c98ac 100644 --- a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs @@ -163,13 +163,13 @@ DELETE FROM assets WHERE item_id = $item_id;"; public bool TryNormalizeLocalAssetPath(string path, out string fullPath) { fullPath = string.Empty; - if (string.IsNullOrWhiteSpace(path) || Uri.TryCreate(path, UriKind.Absolute, out _)) + if (string.IsNullOrWhiteSpace(path)) return false; var relative = path; if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase)) relative = relative[LocalAssetUrlPrefix.Length..]; - else if (Path.IsPathRooted(relative)) + else if (Uri.TryCreate(relative, UriKind.Absolute, out _) || Path.IsPathRooted(relative)) return false; var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative)); diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs index 9715045..74a7eae 100644 --- a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyPrecacheService.cs @@ -1,4 +1,7 @@ using System.Text.Json.Nodes; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Multilang.Services; @@ -6,20 +9,27 @@ namespace Jellyfin.Plugin.Multilang.Services; public sealed class ItemsProxyPrecacheService { private static readonly TimeSpan IdleThreshold = TimeSpan.FromMinutes(30); + private readonly IServerApplicationHost _applicationHost; + private readonly IConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly object _lock = new(); private readonly Dictionary _lastSeen = new(StringComparer.Ordinal); private readonly HashSet _pendingUsers = new(StringComparer.Ordinal); - public ItemsProxyPrecacheService(IHttpClientFactory httpClientFactory, ILogger logger) + public ItemsProxyPrecacheService( + IServerApplicationHost applicationHost, + IConfigurationManager configurationManager, + IHttpClientFactory httpClientFactory, + ILogger logger) { + _applicationHost = applicationHost; + _configurationManager = configurationManager; _httpClientFactory = httpClientFactory; _logger = logger; } public void ObserveUserActivity( - Uri baseUri, string userId, string token, string clientLocale, @@ -38,11 +48,10 @@ public sealed class ItemsProxyPrecacheService return; } - _ = PrecacheAsync(baseUri, userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries); + _ = PrecacheAsync(userId, token, clientLocale, precacheMovieLibraries, precacheTvShowLibraries); } private async Task PrecacheAsync( - Uri baseUri, string userId, string token, string clientLocale, @@ -51,6 +60,8 @@ public sealed class ItemsProxyPrecacheService { 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 items = views["Items"]?.AsArray() ?? []; var targets = items diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs index dfd00a3..c71ddae 100644 --- a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxySorting.cs @@ -82,26 +82,25 @@ public static class ItemsProxySorting public static string[] GetSortArticles( SortArticleCatalog articleCatalog, - IEnumerable configuredArticles, + IReadOnlyList configuredArticles, string? titleLanguage, bool ignoreArticles = true) { if (!ignoreArticles) return []; - var configured = configuredArticles.ToArray(); - var always = configured + var always = configuredArticles .Where(e => e.AlwaysApply == true) .SelectMany(e => SplitArticles(e.Articles)); return always - .Concat(GetLanguageArticles(articleCatalog, configured, titleLanguage)) + .Concat(GetLanguageArticles(articleCatalog, configuredArticles, titleLanguage)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } private static string[] GetLanguageArticles( SortArticleCatalog articleCatalog, - SortArticleEntry[] configured, + IReadOnlyList configured, string? titleLanguage) { var language = (titleLanguage ?? string.Empty).Trim(); diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs index b293782..73063ea 100644 --- a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyTransformer.cs @@ -84,6 +84,7 @@ public sealed class ItemsProxyTransformer ? ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem) : new Dictionary(StringComparer.OrdinalIgnoreCase); var jellyfinTitleLanguages = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sortArticlesByLanguage = new Dictionary(StringComparer.OrdinalIgnoreCase); 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) ? translatedLanguage : 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); } diff --git a/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js b/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js index f7ea06e..4675a21 100644 --- a/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js +++ b/src/Jellyfin.Plugin.Multilang/wwwroot/inject.js @@ -221,6 +221,7 @@ event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); + document.querySelector("#app-user-menu .MuiBackdrop-root")?.click(); history.pushState({}, "", userRulesHref()); scheduleUserRulesPageRender(); } diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/AssetStorageServiceTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/AssetStorageServiceTests.cs new file mode 100644 index 0000000..fa487a6 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/AssetStorageServiceTests.cs @@ -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(() => 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); + } + } +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyCacheTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyCacheTests.cs new file mode 100644 index 0000000..665b279 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyCacheTests.cs @@ -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)); +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs index 3c5c4c7..0111c95 100644 --- a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestCoalescerTests.cs @@ -11,7 +11,7 @@ public sealed class ItemsProxyRequestCoalescerTests var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var fetches = 0; - Task Fetch(CancellationToken _) + Task Fetch(CancellationToken _) { fetches++; return response.Task; diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj b/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj index 8b4da5f..8f72023 100644 --- a/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj +++ b/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/MultilangBackupServiceTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/MultilangBackupServiceTests.cs new file mode 100644 index 0000000..586f5bb --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/MultilangBackupServiceTests.cs @@ -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"; +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ProviderClientTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ProviderClientTests.cs new file mode 100644 index 0000000..eb6fccf --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ProviderClientTests.cs @@ -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") + }); +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/RefreshCoordinatorTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/RefreshCoordinatorTests.cs new file mode 100644 index 0000000..4b7fb8c --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/RefreshCoordinatorTests.cs @@ -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(); + + 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(); + + 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); + } +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/TestSupport.cs b/tests/Jellyfin.Plugin.Multilang.Tests/TestSupport.cs new file mode 100644 index 0000000..02a129b --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/TestSupport.cs @@ -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 responseFactory) : IHttpClientFactory +{ + public List 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 responseFactory) : HttpMessageHandler +{ + protected override Task 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); + } +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreCleanupTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreCleanupTests.cs new file mode 100644 index 0000000..b4b4c1d --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreCleanupTests.cs @@ -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(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); +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreImportTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreImportTests.cs index 6bb311e..5b10ecf 100644 --- a/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreImportTests.cs +++ b/tests/Jellyfin.Plugin.Multilang.Tests/TranslationStoreImportTests.cs @@ -1,7 +1,6 @@ using Jellyfin.Plugin.Multilang.Data; using Jellyfin.Plugin.Multilang.Services.Providers; using Jellyfin.Plugin.Multilang.Services.Refresh; -using MediaBrowser.Common.Configuration; namespace Jellyfin.Plugin.Multilang.Tests; @@ -10,7 +9,7 @@ public sealed class TranslationStoreImportTests [Fact] public void ImportTranslationDatabaseKeepsOnlyLiveItemsAndAllowedLanguages() { - var root = Path.Combine(Path.GetTempPath(), "multilang-test-" + Guid.NewGuid().ToString("N")); + var root = TestPaths.CreateRoot(); try { var source = new TranslationStore(new TestApplicationPaths(Path.Combine(root, "source"))); @@ -38,8 +37,7 @@ public sealed class TranslationStoreImportTests } finally { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + TestPaths.DeleteRoot(root); } } @@ -69,31 +67,4 @@ public sealed class TranslationStoreImportTests .GetValueOrDefault(itemId)? .GetValueOrDefault(lang)? .GetValueOrDefault(field); - - private sealed class TestApplicationPaths(string root) : IApplicationPaths - { - public string ProgramDataPath { get; } = root; - public string WebPath { get; } = Path.Combine(root, "web"); - public string ProgramSystemPath { get; } = Path.Combine(root, "system"); - public string DataPath { get; } = Path.Combine(root, "data"); - public string ImageCachePath { get; } = Path.Combine(root, "images"); - public string PluginsPath { get; } = Path.Combine(root, "plugins"); - public string PluginConfigurationsPath { get; } = Path.Combine(root, "plugin-configs"); - public string LogDirectoryPath { get; } = Path.Combine(root, "logs"); - public string ConfigurationDirectoryPath { get; } = Path.Combine(root, "config"); - public string SystemConfigurationFilePath { get; } = Path.Combine(root, "config", "system.xml"); - public string CachePath { get; } = Path.Combine(root, "cache"); - public string TempDirectory { get; } = Path.Combine(root, "temp"); - public string VirtualDataPath { get; } = "%AppDataPath%"; - public string TrickplayPath { get; } = Path.Combine(root, "trickplay"); - public string BackupPath { get; } = Path.Combine(root, "backups"); - - public void MakeSanityCheckOrThrow() - { - } - - public void CreateAndCheckMarker(string path, string marker, bool isTemporary = false) - { - } - } } diff --git a/tests/browser/README.md b/tests/browser/README.md new file mode 100644 index 0000000..f28c36d --- /dev/null +++ b/tests/browser/README.md @@ -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. diff --git a/tests/browser/multilang.spec.js b/tests/browser/multilang.spec.js new file mode 100644 index 0000000..b6a760c --- /dev/null +++ b/tests/browser/multilang.spec.js @@ -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'] + })]); +}); diff --git a/tests/browser/package-lock.json b/tests/browser/package-lock.json new file mode 100644 index 0000000..5507a41 --- /dev/null +++ b/tests/browser/package-lock.json @@ -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" + } + } + } +} diff --git a/tests/browser/package.json b/tests/browser/package.json new file mode 100644 index 0000000..78d9cd8 --- /dev/null +++ b/tests/browser/package.json @@ -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" + } +} diff --git a/tests/browser/playwright.config.js b/tests/browser/playwright.config.js new file mode 100644 index 0000000..cdd3ce8 --- /dev/null +++ b/tests/browser/playwright.config.js @@ -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' + } +}); diff --git a/tools/run_jellyfin12_browser_tests.py b/tools/run_jellyfin12_browser_tests.py new file mode 100644 index 0000000..e97e0af --- /dev/null +++ b/tools/run_jellyfin12_browser_tests.py @@ -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""" + + {title} + {title} + {title} + English overview for {title}. + English tagline for {title}. + 2024 + 2024-01-{index:02d} + {'Drama' if genre == 'Draama' else 'Comedy'} + en + 99000{index} + +""" + 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()) diff --git a/tools/run_jellyfin12_live_tests.py b/tools/run_jellyfin12_live_tests.py new file mode 100644 index 0000000..6ab0409 --- /dev/null +++ b/tools/run_jellyfin12_live_tests.py @@ -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())