Expand integration coverage and fix settings UI

This commit is contained in:
ajp_anton
2026-07-21 00:42:41 +00:00
parent 4bd6277635
commit a0a535c64a
26 changed files with 1665 additions and 66 deletions
@@ -0,0 +1,85 @@
using System.Net;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class AssetStorageServiceTests
{
[Fact]
public async Task LocalStorageDownloadsAStableAssetPathAndServesIt()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var factory = new TestHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent([1, 2, 3])
});
var service = new AssetStorageService(factory, store);
var path = await service.StoreAsync("item", "fi", "poster", "https://images.example/poster.jpg", true, CancellationToken.None);
Assert.NotNull(path);
Assert.StartsWith(TranslationStore.LocalAssetUrlPrefix, path, StringComparison.Ordinal);
Assert.True(store.TryNormalizeLocalAssetPath(path, out var fullPath));
Assert.Equal([1, 2, 3], await File.ReadAllBytesAsync(fullPath));
Assert.True(service.TryResolveLocalAsset(path[TranslationStore.LocalAssetUrlPrefix.Length..], out var resolved, out var contentType));
Assert.Equal(fullPath, resolved);
Assert.Equal("image/jpeg", contentType);
}
finally
{
TestPaths.DeleteRoot(root);
}
}
[Fact]
public async Task UrlStorageDoesNotFetchOrWriteAnAsset()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var factory = new TestHttpClientFactory(_ => throw new InvalidOperationException("URL storage must not fetch."));
var service = new AssetStorageService(factory, store);
const string url = "https://images.example/poster.webp";
var result = await service.StoreAsync("item", "fi", "poster", url, false, CancellationToken.None);
Assert.Equal(url, result);
Assert.Empty(factory.Requests);
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
}
finally
{
TestPaths.DeleteRoot(root);
}
}
[Fact]
public async Task LocalStorageRejectsAnOversizedResponseBeforeWritingIt()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var factory = new TestHttpClientFactory(_ =>
{
var content = new ByteArrayContent([1]);
content.Headers.ContentLength = 51L * 1024L * 1024L;
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
});
var service = new AssetStorageService(factory, store);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.StoreAsync("item", "fi", "poster", "https://images.example/poster.jpg", true, CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
}
finally
{
TestPaths.DeleteRoot(root);
}
}
}
@@ -0,0 +1,86 @@
using Jellyfin.Plugin.Multilang.Services;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxyCacheTests
{
[Fact]
public void StoreRetrievesAndInvalidatesByItemId()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "key", ["A"]));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
Assert.Equal("body-key", response.Body);
Assert.Equal(1, response.ItemCount);
cache.InvalidateItems(["a"]);
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void MicroCacheServesFastResponsesAndIsInvalidatedWithTheMainCache()
{
var cache = new ItemsProxyCache();
Assert.True(cache.StoreMicro("key", "user", "/Items", ["A"], "micro", "application/json", 1, 1024));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
Assert.Equal("micro", response.Body);
cache.InvalidateItems(["A"]);
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void StoreEvictsTheOldestEntryWhenCapacityIsExceeded()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "first", ["A"], body: "1111", maxBytes: 6));
Thread.Sleep(10);
Assert.True(Store(cache, "second", ["B"], body: "2222", maxBytes: 6));
Assert.False(cache.TryGet("first", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("second", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void StoreEntriesExpireAtTheConfiguredTtl()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "key", ["A"], ttl: TimeSpan.FromMilliseconds(1)));
Thread.Sleep(20);
Assert.False(cache.TryGet("key", TimeSpan.FromMilliseconds(1), out _));
}
[Fact]
public void StoreRejectsResponsesLargerThanTheConfiguredLimit()
{
var cache = new ItemsProxyCache();
Assert.False(Store(cache, "key", ["A"], body: "12345", maxBytes: 4));
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
private static bool Store(
ItemsProxyCache cache,
string key,
string[] itemIds,
string? body = null,
long maxBytes = 1024,
TimeSpan? ttl = null)
=> cache.Store(
key,
"user",
"/Items",
itemIds,
body ?? "body-" + key,
"application/json",
100,
maxBytes,
ttl ?? TimeSpan.FromMinutes(1));
}
@@ -11,7 +11,7 @@ public sealed class ItemsProxyRequestCoalescerTests
var response = new TaskCompletionSource<ItemsProxyUpstreamResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
var fetches = 0;
Task<ItemsProxyUpstreamResponse> Fetch(CancellationToken _)
Task<ItemsProxyUpstreamResponse> Fetch(CancellationToken _)
{
fetches++;
return response.Task;
@@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Jellyfin.Common" Version="10.11.9" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -0,0 +1,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.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)
{
}
}
}
+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'
}
});