Release 0.2.5: harden proxy and streamline metadata fetching

Remove obsolete rule formats, batch SQLite work, preserve artwork source URLs, and coordinate refresh workers with maintenance. Add regression coverage and document backup format changes.
This commit is contained in:
ajp_anton
2026-09-09 01:18:43 +00:00
parent 7eb62b156e
commit e36775f1a3
49 changed files with 2126 additions and 1813 deletions
@@ -19,7 +19,7 @@ public sealed class AssetStorageServiceTests
});
var service = new AssetStorageService(factory, store);
var path = await service.StoreAsync("item", "fi", "poster", "https://images.example/poster.jpg", true, CancellationToken.None);
var path = await service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None);
Assert.NotNull(path);
Assert.StartsWith(TranslationStore.LocalAssetUrlPrefix, path, StringComparison.Ordinal);
@@ -28,6 +28,19 @@ public sealed class AssetStorageServiceTests
Assert.True(service.TryResolveLocalAsset(path[TranslationStore.LocalAssetUrlPrefix.Length..], out var resolved, out var contentType));
Assert.Equal(fullPath, resolved);
Assert.Equal("image/jpeg", contentType);
store.UpsertAsset("item", "fi", "poster", path, 1);
var original = await service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None);
store.UpsertAsset("item", "Original", "poster", original!, 1);
Assert.Equal(path, original);
Assert.Single(factory.Requests);
Assert.True(store.IsAssetReferenced("item", path));
Assert.True(File.Exists(fullPath));
File.Delete(fullPath);
Assert.False(File.Exists(fullPath));
await service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None);
Assert.True(File.Exists(fullPath));
Assert.Equal(2, factory.Requests.Count);
}
finally
{
@@ -46,7 +59,7 @@ public sealed class AssetStorageServiceTests
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);
var result = await service.StoreAsync("item", url, false, CancellationToken.None);
Assert.Equal(url, result);
Assert.Empty(factory.Requests);
@@ -73,7 +86,7 @@ public sealed class AssetStorageServiceTests
});
var service = new AssetStorageService(factory, store);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.StoreAsync("item", "fi", "poster", "https://images.example/poster.jpg", true, CancellationToken.None));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.StoreAsync("item", "https://images.example/poster.jpg", true, CancellationToken.None));
Assert.Empty(Directory.EnumerateFiles(store.AssetsDirectory, "*", SearchOption.AllDirectories));
}
@@ -6,6 +6,24 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class CategoryRuleEvaluatorTests
{
[Fact]
public void FastMatchingAndDiagnosticTraceAgreeAcrossAllRuleModes()
{
foreach (var relation in new[] { "is", "is_not", "contains", "not_contains" })
foreach (var fieldOr in new[] { false, true })
foreach (var valueOr in new[] { false, true })
foreach (var matchAll in new[] { false, true })
foreach (var facts in new[] { Facts(originalLanguage: "sv"), Facts(spokenLanguages: ["fi", "en"]), Facts(kind: "tv") })
{
var rule = Rule("original_language", relation, valueOr, "sv", "fi");
rule.Requirements[0].Fields = ["original_language", "spoken_languages"];
rule.Requirements[0].UseFieldOr = fieldOr;
rule.Requirements = [rule.Requirements[0], Requirement("production_countries", "contains", false, "FI")];
rule.MatchAllConditions = matchAll;
Assert.Equal(CategoryRuleEvaluator.Trace(rule, facts).Result, CategoryRuleEvaluator.Matches(rule, facts));
}
}
[Fact]
public void IsWithAndListRequiresExactSet()
{
@@ -95,11 +113,9 @@ public sealed class CategoryRuleEvaluatorTests
{
Id = rule.Id,
Label = rule.Label,
CriteriaText = rule.CriteriaText,
Requirements = rule.Requirements,
MatchAllConditions = matchAll,
Scopes = rule.Scopes,
FieldActions = rule.FieldActions,
FieldActionLists = rule.FieldActionLists
};
@@ -0,0 +1,232 @@
using System.Net;
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
using Jellyfin.Plugin.Multilang.Services.Providers;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemFetcherTests : IDisposable
{
private readonly string _root = TestPaths.CreateRoot();
private readonly TranslationStore _store;
private readonly TestHttpClientFactory _http;
private readonly ItemFetcher _fetcher;
private readonly RefreshItemInfo _item = new("11111111111111111111111111111111", "1", "movie", RefreshWorkClass.Movies, 0, 0, null, "Movie");
private readonly PluginConfiguration _config = new()
{
Languages = ["en", "fi"],
TmdbApiKey = "test",
Providers = [new() { Id = "tmdb", MetadataOrder = 0 }]
};
private string _original = "en";
private string _overview = "Overview";
private bool _missingFinnishTagline;
private bool _notFound;
private bool _missingTmdbPoster;
private bool _fanartUnauthorized;
public ItemFetcherTests()
{
_store = new(new TestApplicationPaths(_root));
_http = new(request =>
{
if (_notFound) return new(HttpStatusCode.NotFound);
var url = request.RequestUri!;
if (url.Host == "webservice.fanart.tv")
return _fanartUnauthorized ? new(HttpStatusCode.Unauthorized)
: Json(new { movieposter = new[] { new { lang = "en", url = "https://assets.fanart.tv/poster.jpg", likes = "5" } } });
if (url.Host == "image.tmdb.org")
return new(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3]) };
if (url.AbsolutePath.EndsWith("/images"))
return Json(new { posters = _missingTmdbPoster ? [] : new[] { new { iso_639_1 = "en", file_path = "/poster.jpg" } }, logos = Array.Empty<object>(), backdrops = Array.Empty<object>() });
var language = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(url.Query)["language"].ToString();
return Json(new
{
title = language + " title",
overview = _overview,
tagline = language == "fi" && _missingFinnishTagline ? "" : "Tagline",
original_language = _original,
original_title = "Original title",
genres = Array.Empty<object>()
});
});
_fetcher = new(_store, new(_http), new(_http), new(_http, _store), NullLogger<ItemFetcher>.Instance);
}
private static HttpResponseMessage Json(object value)
=> new(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(value)) };
private Task<bool> Fetch(RefreshJobType job = RefreshJobType.Missing) => _fetcher.RefreshAsync(_item, _config, job, CancellationToken.None);
private string Text(string language, string field) => _store.GetTranslations([_item.ItemId], [language])[_item.ItemId][language][field];
[Fact]
public async Task CompleteMetadataIsNotRefetchedAndOriginalReusesConfiguredLanguage()
{
Assert.True(await Fetch());
Assert.Equal(2, _http.Requests.Count);
Assert.Equal(Text("en", "title"), Text("Original", "title"));
Assert.False(_fetcher.NeedsRefresh(_item, _config));
Assert.False(await Fetch());
Assert.Equal(2, _http.Requests.Count);
await Fetch(RefreshJobType.Full);
Assert.Equal(4, _http.Requests.Count);
}
[Fact]
public async Task MissingRetryFetchesOnlyIncompleteLanguageAndUpdatesWholeResponse()
{
_missingFinnishTagline = true;
await Fetch();
Assert.False(_fetcher.NeedsRefresh(_item, _config));
await Fetch();
Assert.Equal(2, _http.Requests.Count);
_config.WaitDaysForMissingData = 0;
Assert.True(_fetcher.NeedsRefresh(_item, _config));
_missingFinnishTagline = false;
_overview = "Updated overview";
await Fetch();
Assert.Equal(3, _http.Requests.Count);
Assert.Contains("language=fi", _http.Requests.Last());
Assert.Equal("Updated overview", Text("fi", "overview"));
Assert.Equal("Overview", Text("en", "overview"));
Assert.False(_fetcher.NeedsRefresh(_item, _config));
}
[Fact]
public async Task AdditionalOriginalLanguageIsFetchedOnceAndNewConfiguredLanguageDoesNotRefetchOthers()
{
_original = "sv";
await Fetch();
Assert.Equal(3, _http.Requests.Count);
Assert.Equal("sv title", Text("Original", "title"));
await Fetch();
Assert.Equal(3, _http.Requests.Count);
_config.Languages = ["en", "fi", "fr"];
await Fetch();
Assert.Equal(4, _http.Requests.Count);
Assert.Contains("language=fr", _http.Requests.Last());
}
[Fact]
public async Task NotFoundIsRememberedUntilRetryCutoff()
{
_notFound = true;
Assert.False(await Fetch());
Assert.False(_fetcher.NeedsRefresh(_item, _config));
await Fetch();
Assert.Equal(2, _http.Requests.Count);
_config.WaitDaysForMissingData = 0;
Assert.True(_fetcher.NeedsRefresh(_item, _config));
await Fetch();
Assert.Equal(4, _http.Requests.Count);
}
[Fact]
public async Task LocalRepairDoesNotFetchMetadataAndUrlCleanupOnlyHappensInScheduledHousekeeping()
{
_config.Providers[0].ArtworkOrder = 0;
await Fetch();
Assert.Equal(3, _http.Requests.Count);
_config.AssetStorageMode = "local";
Assert.True(_fetcher.NeedsLocalWork(_item.ItemId, _config));
await Fetch(RefreshJobType.Aggregate);
Assert.Equal(4, _http.Requests.Count);
Assert.Contains("image.tmdb.org", _http.Requests.Last());
var path = _store.GetAssetPath(_item.ItemId, "en", "poster")!;
Assert.Equal(path, _store.GetAssetPath(_item.ItemId, "Original", "poster"));
Assert.True(_store.TryNormalizeLocalAssetPath(path, out var file));
File.Delete(file);
await Fetch();
Assert.Equal(5, _http.Requests.Count);
Assert.True(File.Exists(file));
_config.AssetStorageMode = "url";
await Fetch();
Assert.Equal(path, _store.GetAssetPath(_item.ItemId, "en", "poster"));
Assert.True(File.Exists(file));
_store.CleanupForConfiguration(new HashSet<string> { _item.ItemId }, _config.Languages, false);
Assert.StartsWith("https://", _store.GetAssetPath(_item.ItemId, "en", "poster"));
Assert.False(File.Exists(file));
Assert.Equal(5, _http.Requests.Count);
}
[Fact]
public async Task ArtworkUsesProviderPriorityAndFallsBackWhenHigherPriorityImageDisappears()
{
_config.Providers = [new() { Id = "tmdb", MetadataOrder = 0, ArtworkOrder = 0 }, new() { Id = "fanart", ArtworkOrder = 1 }];
_config.FanartApiKey = "test";
await Fetch();
Assert.Contains("image.tmdb.org", _store.GetAssetPath(_item.ItemId, "en", "poster"));
_missingTmdbPoster = true;
await Fetch(RefreshJobType.Full);
Assert.Contains("assets.fanart.tv", _store.GetAssetPath(_item.ItemId, "en", "poster"));
_fanartUnauthorized = true;
await Assert.ThrowsAsync<HttpRequestException>(() => Fetch(RefreshJobType.Full));
Assert.Contains("assets.fanart.tv", _store.GetAssetPath(_item.ItemId, "en", "poster"));
}
[Fact]
public async Task BackupPreservesSourceUrlsAndFetchStateAndFiltersUnwantedLanguages()
{
_config.Providers[0].ArtworkOrder = 0;
await Fetch();
var backup = Path.Combine(_root, "export.sqlite");
_store.ExportTranslationDatabase(backup);
_store.ResetAll();
_store.ImportTranslationDatabase(backup, new HashSet<string> { _item.ItemId }, new HashSet<string> { "en" });
Assert.Equal(2, _store.GetFetchStates(_item.ItemId).Count);
Assert.DoesNotContain("metadata:fi", _store.GetFetchStates(_item.ItemId).Keys);
Assert.Equal("https://image.tmdb.org/t/p/original/poster.jpg", _store.GetStoredAssets(_item.ItemId).First().SourceUrl);
Assert.Equal("tmdb", _store.GetStoredAssets(_item.ItemId).First().Provider);
}
[Theory]
[InlineData(true, false, false)]
[InlineData(true, true, true)]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
public void YoungFetchStateHonorsKnownMissingVersusDeletedData(bool complete, bool missing, bool expected)
=> Assert.Equal(expected, ItemFetcher.NeedsFetch(new("scope", 100, complete), "scope", missing, false, 50));
[Fact]
public void FailedArtworkPacketRollsBackDeletionAndNewRowsTogether()
{
var original = new StoredAsset("en", "poster", "https://example.test/old.jpg", "https://example.test/old.jpg", "tmdb");
_store.SaveArtwork(_item.ItemId, [original], new Dictionary<string, FetchState>());
Assert.Throws<Microsoft.Data.Sqlite.SqliteException>(() => _store.SaveArtwork(_item.ItemId,
[original with { Path = "new" }, original], new Dictionary<string, FetchState>()));
Assert.Equal(original, Assert.Single(_store.GetStoredAssets(_item.ItemId)));
}
[Fact]
public async Task ParallelMetadataPacketsDoNotLoseRows()
{
await Task.WhenAll(Enumerable.Range(0, 12).Select(index => Task.Run(() =>
_store.SaveMetadata(_item with { ItemId = index.ToString() }, "en",
new("Title", "Overview", "Tagline", "Title", "en", [], [], [], []),
new("scope", 1, true), true))));
foreach (var index in Enumerable.Range(0, 12))
{
Assert.NotNull(_store.GetFacts(index.ToString()));
Assert.Equal(3, _store.GetTranslations([index.ToString()], ["en"])[index.ToString()]["en"].Count);
Assert.True(_store.GetFetchStates(index.ToString())["metadata:en"].Complete);
}
}
[Fact]
public void CollectionAggregationKeepsOnlySharedSingleLanguageAndUnionsCountrySets()
{
var collection = new FactsData("collection", "1", "collection", "Collection", "en", "en", "[]", "[]", "[]", "", "[]", 1, 1);
var finnish = collection with { Kind = "movie", OriginalLanguage = "fi", ProductionCountriesJson = "[\"FI\"]" };
var swedish = finnish with { OriginalLanguage = "sv", ProductionCountriesJson = "[\"SE\",\"FI\"]" };
var mixed = Services.ItemsProxyTransformer.AggregateCollectionFacts(collection, [finnish, swedish]);
Assert.Equal("", mixed.OriginalLanguage);
Assert.Equal("fi,sv", mixed.OriginalLanguageAll);
Assert.Equal(new HashSet<string> { "FI", "SE" }, JsonSerializer.Deserialize<HashSet<string>>(mixed.ProductionCountriesJson));
Assert.Equal("fi", Services.ItemsProxyTransformer.AggregateCollectionFacts(collection, [finnish, finnish]).OriginalLanguage);
}
public void Dispose() => TestPaths.DeleteRoot(_root);
}
@@ -5,16 +5,29 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxyCacheTests
{
[Fact]
public void StoreRetrievesAndInvalidatesByItemId()
public void PolicyChangesInvalidateCachedDataAndOldInFlightWrites()
{
var cache = new ItemsProxyCache();
var generation = cache.ObserveUserPolicy("user", "all libraries");
Store(cache, "key");
Assert.Equal(generation, cache.ObserveUserPolicy("user", "all libraries"));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.ObserveUserPolicy("user", "no libraries") > generation);
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
Assert.False(cache.Store("key", "user", "/Items", 1, "body", "application/json", 1, 100, TimeSpan.FromMinutes(1), generation));
}
[Fact]
public void StoreRetrievesAndClearsCachedResponses()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "key", ["A"]));
Assert.True(Store(cache, "key"));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
Assert.Equal("body-key", response.Body);
Assert.Equal(1, response.ItemCount);
cache.InvalidateItems(["a"]);
cache.ClearAll();
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
@@ -24,11 +37,11 @@ public sealed class ItemsProxyCacheTests
{
var cache = new ItemsProxyCache();
Assert.True(cache.StoreMicro("key", "user", "/Items", ["A"], "micro", "application/json", 1, 1024));
Assert.True(cache.StoreMicro("key", "user", "/Items", 1, "micro", "application/json", 1, 1024));
Assert.True(cache.TryGet("key", TimeSpan.FromMinutes(1), out var response));
Assert.Equal("micro", response.Body);
cache.InvalidateItems(["A"]);
cache.ClearAll();
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
@@ -38,9 +51,9 @@ public sealed class ItemsProxyCacheTests
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "first", ["A"], body: "1111", maxBytes: 6));
Assert.True(Store(cache, "first", body: "1111", maxBytes: 6));
Thread.Sleep(10);
Assert.True(Store(cache, "second", ["B"], body: "2222", maxBytes: 6));
Assert.True(Store(cache, "second", body: "2222", maxBytes: 6));
Assert.False(cache.TryGet("first", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("second", TimeSpan.FromMinutes(1), out _));
@@ -51,7 +64,7 @@ public sealed class ItemsProxyCacheTests
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "key", ["A"], ttl: TimeSpan.FromMilliseconds(1)));
Assert.True(Store(cache, "key", ttl: TimeSpan.FromMilliseconds(1)));
Thread.Sleep(20);
Assert.False(cache.TryGet("key", TimeSpan.FromMilliseconds(1), out _));
@@ -62,14 +75,14 @@ public sealed class ItemsProxyCacheTests
{
var cache = new ItemsProxyCache();
Assert.False(Store(cache, "key", ["A"], body: "12345", maxBytes: 4));
Assert.False(Store(cache, "key", body: "12345", maxBytes: 4));
Assert.False(cache.TryGet("key", TimeSpan.FromMinutes(1), out _));
}
private static bool Store(
ItemsProxyCache cache,
string key,
string[] itemIds,
int itemCount = 1,
string? body = null,
long maxBytes = 1024,
TimeSpan? ttl = null)
@@ -77,10 +90,43 @@ public sealed class ItemsProxyCacheTests
key,
"user",
"/Items",
itemIds,
itemCount,
body ?? "body-" + key,
"application/json",
100,
maxBytes,
ttl ?? TimeSpan.FromMinutes(1));
[Fact]
public void MicroAndMainEntriesShareOneBudget()
{
var cache = new ItemsProxyCache();
Assert.True(Store(cache, "main", body: "1111", maxBytes: 6));
Assert.True(cache.StoreMicro("micro1", "user", "/Items", 1, "2222", "application/json", 1, 6));
Assert.False(cache.TryGet("main", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.StoreMicro("micro2", "user", "/Items", 1, "3333", "application/json", 1, 6));
Assert.False(cache.TryGet("micro1", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("micro2", TimeSpan.FromMinutes(1), out _));
}
[Fact]
public void InvalidationRejectsStaleInFlightWrites()
{
var cache = new ItemsProxyCache();
var generation = cache.Generation;
cache.ClearAll();
Assert.False(cache.Store("key", "user", "/Items", 1, "body", "application/json", 1, 100, TimeSpan.FromMinutes(1), generation));
Assert.False(cache.StoreMicro("key", "user", "/Items", 1, "body", "application/json", 1, 100, generation));
}
[Fact]
public void ClearingOneUserPreservesOtherUsersCachedResponses()
{
var cache = new ItemsProxyCache();
Store(cache, "one");
cache.Store("two", "other", "/Items", 1, "body", "application/json", 1, 100, TimeSpan.FromMinutes(1));
cache.ClearUser("user");
Assert.False(cache.TryGet("one", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("two", TimeSpan.FromMinutes(1), out _));
}
}
@@ -16,14 +16,13 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?Recursive=true&api_key=old",
"token",
UserId,
multilangEnabled: true);
Assert.NotNull(result);
Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath);
Assert.Contains("api_key=token", result.Upstream.Query);
Assert.DoesNotContain("api_key=old", result.Upstream.Query);
Assert.DoesNotContain("api_key", result.Upstream.Query, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("api_key", result.NormalizedUrlForCache, StringComparison.OrdinalIgnoreCase);
Assert.Contains("userId=" + UserId, result.NormalizedUrlForCache);
}
@@ -36,7 +35,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
$"/Users/{OtherUserId}/Items?Limit=5",
"token",
UserId,
multilangEnabled: true);
@@ -52,7 +50,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L",
"token",
UserId,
multilangEnabled: true);
@@ -65,7 +62,7 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Equal("fi-FI", result.Controls.ClientLocale);
Assert.DoesNotContain("SortBy=", result.Upstream.Query);
Assert.DoesNotContain("Limit=", result.Upstream.Query);
Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey);
Assert.DoesNotContain("SortBy=", result.CacheKey);
}
[Fact]
@@ -76,20 +73,18 @@ public sealed class ItemsProxyRequestBuilderTests
var first = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&StartIndex=0&Limit=100",
"token",
UserId,
multilangEnabled: true);
var second = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&StartIndex=100&Limit=100",
"token",
UserId,
multilangEnabled: true);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache);
Assert.NotEqual(first.CacheKey, second.CacheKey);
Assert.Equal(first.CacheKey, second.CacheKey);
}
[Fact]
@@ -100,7 +95,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"/Items?SortBy=SortName&Limit=10",
"token",
UserId,
multilangEnabled: false);
@@ -119,7 +113,6 @@ public sealed class ItemsProxyRequestBuilderTests
var result = ItemsProxyRequestBuilder.Build(
request,
"http://other-host:8096/Items",
"token",
UserId,
multilangEnabled: true);
@@ -132,6 +125,18 @@ public sealed class ItemsProxyRequestBuilderTests
Assert.Equal([1, 2], ItemsProxyRequestBuilder.ParseLocalGenreIds("tmdb-1,abc,2,tmdb-2,tmdb-x,1"));
}
[Fact]
public void GenreFilteringRetainsTheFieldsNeededForLocalFiltering()
{
var result = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?GenreIds=tmdb-18&Fields=Overview", UserId, true);
Assert.NotNull(result);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(result.Upstream.Query);
Assert.False(query.ContainsKey("GenreIds"));
Assert.Equal(1, query["Fields"].Count);
Assert.Contains("GenreItems", query["Fields"].ToString());
Assert.Contains("Overview", query["Fields"].ToString());
}
private static HttpRequest HttpRequest(string query = "")
{
var context = new DefaultHttpContext();
@@ -140,4 +145,40 @@ public sealed class ItemsProxyRequestBuilderTests
context.Request.QueryString = new QueryString(query);
return context.Request;
}
[Theory]
[InlineData("//other-host:8096/Items")]
[InlineData("/\\other-host/Items")]
[InlineData("https://server.local:8096/Items")]
[InlineData("/Multilang/ItemsProxy")]
[InlineData("/System/Configuration")]
[InlineData("/Items/Filters")]
[InlineData("/Items/../System/Info")]
public void RejectsUnsupportedDestinations(string url)
=> Assert.Null(ItemsProxyRequestBuilder.Build(HttpRequest(), url, UserId, true));
[Fact]
public void EquivalentQueryOrderSharesCacheAndInFlightKeys()
{
var first = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?Fields=Overview&Recursive=true", UserId, true)!;
var second = ItemsProxyRequestBuilder.Build(HttpRequest(), "/Items?Recursive=true&Fields=Overview", UserId, true)!;
Assert.Equal(first.CacheKey, second.CacheKey);
Assert.Equal(first.NormalizedUrlForCache, second.NormalizedUrlForCache);
}
[Fact]
public void BasePathAndDashedUserIdsAreNormalized()
{
var request = HttpRequest();
request.PathBase = "/jellyfin";
var result = ItemsProxyRequestBuilder.Build(request, "/jellyfin/Users/22222222-2222-2222-2222-222222222222/Items", UserId, true)!;
Assert.Equal($"/jellyfin/Users/{UserId}/Items", result.Upstream.AbsolutePath);
Assert.Null(ItemsProxyRequestBuilder.Build(request, "/Items", UserId, true));
}
[Theory]
[InlineData("/Items/Resume?StartIndex=100&Limit=20")]
[InlineData("/Shows/NextUp?StartIndex=100&Limit=20")]
public void BoundedListsKeepUpstreamPaging(string url)
=> Assert.False(ItemsProxyRequestBuilder.Build(HttpRequest(), url, UserId, true)!.Controls.ApplyLocalPaging);
}
@@ -7,6 +7,14 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxySortingTests
{
[Fact]
public void TranslationOnlyDoesNotRewriteUpstreamPaging()
{
var root = JsonNode.Parse("""{"Items":[{"Name":"One"},{"Name":"Two"}],"TotalRecordCount":200,"StartIndex":100}""")!;
var original = root.ToJsonString();
ItemsProxySorting.Apply(root, new ItemsProxyControls("", "", "", 0, 0, "", "", false), CultureInfo.InvariantCulture, []);
Assert.Equal(original, root.ToJsonString());
}
[Fact]
public void GetSortArticlesUsesBuiltInLanguageFallback()
{
@@ -13,6 +13,22 @@ public sealed class ItemsProxyTransformerResolutionTests
private const string OriginalAction = "Original";
private const string LanguagePrefix = "Language:";
[Fact]
public void TraceUsesStoredOriginalTranslationWhenOriginalTitleIsEmpty()
{
var facts = Facts(originalTitle: "", originalLanguage: "fi");
var translations = TextTranslations((OriginalAction, TitleField, "Alkuperainen nimi"));
var attempts = new List<ItemsProxyResolutionAttempt>();
var normal = ItemsProxyTransformer.ResolveField(TitleField, [OriginalAction], facts, translations);
var traced = ItemsProxyTransformer.ResolveField(TitleField, [OriginalAction], facts, translations, attempts.Add);
Assert.Equal(normal, traced);
Assert.Equal("fi", traced.Language);
var chosen = Assert.Single(attempts);
Assert.True(chosen.Chosen);
Assert.Equal("Original/title", chosen.LookupKey);
Assert.Equal(traced.Value, chosen.Value);
}
[Fact]
public void ResolveFieldUsesFirstLanguageWithData()
{
@@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Net;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Providers;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class MaintenanceAndProviderTests
{
[Fact]
public async Task ConcurrentRefreshesCanOverlapButMaintenanceWaitsForAllOfThem()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var first = await store.EnterRefreshAsync(CancellationToken.None);
var second = await store.EnterRefreshAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
using var cancel = new CancellationTokenSource();
var maintenance = store.EnterMaintenanceAsync(cancel.Token);
Assert.False(maintenance.IsCompleted);
first.Dispose();
Assert.False(maintenance.IsCompleted);
cancel.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => maintenance);
second.Dispose();
using var allSlots = await store.EnterMaintenanceAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
}
finally { TestPaths.DeleteRoot(root); }
}
[Fact]
public async Task MaintenanceWaitsForWriterAndCanceledWaiterDoesNotLeakLease()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var writer = await store.EnterMaintenanceAsync(CancellationToken.None);
using var canceled = new CancellationTokenSource();
var waiting = store.EnterMaintenanceAsync(canceled.Token);
Assert.False(waiting.IsCompleted);
canceled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waiting);
var cleanup = store.EnterMaintenanceAsync(CancellationToken.None);
Assert.False(cleanup.IsCompleted);
writer.Dispose();
using var lease = await cleanup.WaitAsync(TimeSpan.FromSeconds(5));
}
finally { TestPaths.DeleteRoot(root); }
}
[Fact]
public void ResetKeepsExistingConnectionsOnTheSameDatabase()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
store.SaveUserRules("user", new UserRulesDocument());
using var connection = store.Open();
connection.Open();
using var query = connection.CreateCommand();
query.CommandText = "SELECT COUNT(*) FROM user_rules;";
Assert.Equal(1L, query.ExecuteScalar());
store.ResetAll();
Assert.Equal(0L, query.ExecuteScalar());
}
finally { TestPaths.DeleteRoot(root); }
}
[Fact]
public async Task ProviderDoesNotRetryProgrammingErrors()
{
var attempts = 0;
using var http = new HttpClient(new TestHttpMessageHandler(_ =>
{
attempts++;
throw new InvalidOperationException("Programming error");
}));
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/");
await Assert.ThrowsAsync<InvalidOperationException>(() => ProviderHttp.SendWithRetryAsync(http, request, CancellationToken.None));
Assert.Equal(1, attempts);
}
[Fact]
public async Task ConcurrentProviderAdmissionsKeepTheirSpacing()
{
var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(50));
var starts = await Task.WhenAll(Enumerable.Range(0, 4).Select(async _ =>
{
await limiter.WaitAsync(CancellationToken.None);
return Stopwatch.GetTimestamp();
}));
Array.Sort(starts);
Assert.All(starts.Zip(starts.Skip(1)), pair =>
Assert.True(Stopwatch.GetElapsedTime(pair.First, pair.Second) >= TimeSpan.FromMilliseconds(40)));
}
}
@@ -5,10 +5,76 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class RefreshCoordinatorTests
{
[Fact]
public async Task IndependentItemsOverlapWithinWorkerLimitAndDuplicateWorkIsShared()
{
using var coordinator = new RefreshCoordinator(concurrency: 2);
var twoStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0;
Task<bool> Queue(string id) => coordinator.EnqueueAsync(id, RefreshSourceTier.Manual, RefreshWorkClass.Movies,
RefreshJobType.Full, async (_, _) =>
{
if (Interlocked.Increment(ref count) == 2) twoStarted.SetResult();
await release.Task;
return true;
}, CancellationToken.None);
var first = Queue("first");
var second = Queue("second");
await twoStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Same(first, Queue("first"));
var third = Queue("third");
Assert.Equal(2, count);
Assert.Equal(2, coordinator.GetDiagnostics().ActiveItems.Length);
release.SetResult();
await Task.WhenAll(first, second, third).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(3, count);
}
[Fact]
public async Task ActiveMissingRefreshRunsFullUpgradeBeforeCompleting()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var fullRan = false;
var first = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Missing, async (_, _) =>
{
started.SetResult();
await release.Task;
return false;
}, CancellationToken.None);
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
var full = coordinator.EnqueueAsync("item", RefreshSourceTier.Manual, RefreshWorkClass.Movies, RefreshJobType.Full, (_, _) =>
{
fullRan = true;
return Task.FromResult(true);
}, CancellationToken.None);
release.SetResult();
Assert.True(await full.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.True(fullRan);
Assert.True(await first);
}
[Fact]
public async Task CanceledWaiterDoesNotCancelAnotherWaiter()
{
using var coordinator = new RefreshCoordinator(concurrency: 1);
using var cancel = new CancellationTokenSource();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var canceled = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full,
async (_, _) => { await release.Task; return true; }, cancel.Token);
var survivor = coordinator.EnqueueAsync("item", RefreshSourceTier.Background, RefreshWorkClass.Movies, RefreshJobType.Full,
(_, _) => Task.FromResult(false), CancellationToken.None);
cancel.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => canceled);
release.SetResult();
Assert.True(await survivor.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task QueuedItemsUseSourceTierThenWorkClassPriority()
{
var coordinator = new RefreshCoordinator(new ItemsProxyCache());
using var coordinator = new RefreshCoordinator(concurrency: 1);
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var order = new List<string>();
@@ -44,11 +110,11 @@ public sealed class RefreshCoordinatorTests
}
[Fact]
public async Task DuplicateQueuedItemUsesTheHighestJobAndTierAndInvalidatesCache()
public async Task DuplicateQueuedItemUsesHighestJobAndTierWithoutInvalidatingUpstreamData()
{
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);
Assert.True(cache.Store("target", "user", "/Items", 1, "body", "application/json", 1, 1024, TimeSpan.FromMinutes(1)));
using var coordinator = new RefreshCoordinator(concurrency: 1);
var activeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseActive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var runs = new List<RefreshJobType>();
@@ -77,7 +143,7 @@ public sealed class RefreshCoordinatorTests
await Task.WhenAll(active, full);
Assert.Equal([RefreshJobType.Full], runs);
Assert.False(cache.TryGet("target", TimeSpan.FromMinutes(1), out _));
Assert.True(cache.TryGet("target", TimeSpan.FromMinutes(1), out _));
var recent = coordinator.GetDiagnostics().Recent.Single(item => item.ItemId == "target");
Assert.Equal("Manual", recent.SourceTier);
Assert.Equal("Full", recent.JobType);
@@ -0,0 +1,33 @@
using System.Diagnostics;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Refresh;
using Xunit.Abstractions;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class TranslationStoreBatchTests(ITestOutputHelper output)
{
[Fact]
public void BatchFactsLookupMatchesIndividualReads()
{
var root = TestPaths.CreateRoot();
try
{
var store = new TranslationStore(new TestApplicationPaths(root));
var ids = Enumerable.Range(0, 100).Select(index => index.ToString()).ToArray();
foreach (var id in ids)
store.SaveMetadata(new(id, id, "movie", RefreshWorkClass.Movies, 0, 0, null, "Movie"), "fi",
new("Title", "Overview", "Tagline", "Title", "fi", [], [], [], []), new("scope", 1, true), true);
var timer = Stopwatch.StartNew();
var individual = ids.Select(id => store.GetFacts(id)!).ToArray();
var separateMs = timer.Elapsed.TotalMilliseconds;
timer.Restart();
var batch = store.GetFactsForItems(ids.Concat(ids));
var batchMs = timer.Elapsed.TotalMilliseconds;
Assert.Equal(individual, ids.Select(id => batch[id]));
Assert.Equal(ids.Length, batch.Count);
output.WriteLine($"100 individual fact reads: {separateMs:F2} ms; one batched read: {batchMs:F2} ms. Timing is diagnostic, not a pass condition.");
}
finally { TestPaths.DeleteRoot(root); }
}
}
@@ -34,7 +34,7 @@ public sealed class TranslationStoreCleanupTests
["en"],
localAssetStorage: false);
Assert.Equal(new LocalCleanupResult(TranslationsDeleted: 1, AssetsDeleted: 2, GenresDeleted: 1, AssetFilesDeleted: 2), result);
Assert.Equal(new LocalCleanupResult(TranslationsDeleted: 1, AssetsDeleted: 2, GenresDeleted: 1, AssetFilesDeleted: 4), result);
Assert.NotNull(store.GetFacts(live));
Assert.Null(store.GetFacts(stale));
Assert.Equal("English", Translation(store, live, "en", "title"));
@@ -0,0 +1,61 @@
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Rules;
namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class UserRulesNormalizerTests
{
[Fact]
public void LegacyFieldsAreNotConvertedOrWrittenBack()
{
var rules = JsonSerializer.Deserialize<UserRulesDocument>("""
{"Categories":[{"CriteriaText":"original_language is fi",
"FieldActions":{"title":"Language:fi"},
"Requirements":[{"Field":"original_language","Relation":"is","Values":["fi"]}]}]}
""")!;
UserRulesNormalizer.Normalize(rules, ["fi"]);
var category = Assert.Single(rules.Categories);
Assert.Empty(category.Requirements);
Assert.Equal(["Jellyfin"], category.FieldActionLists["title"]);
var saved = JsonSerializer.Serialize(rules);
Assert.DoesNotContain("CriteriaText", saved);
Assert.DoesNotContain("\"Field\":", saved);
Assert.DoesNotContain("\"FieldActions\":", saved);
}
[Fact]
public void CurrentRulesPreserveBooleanModesAndNormalizeOnlyCompleteConditions()
{
var rules = new UserRulesDocument
{
Categories = [new()
{
Label = "Finnish", MatchAllConditions = false, Scopes = ["M", "C"],
Requirements = [new() { Fields = ["audio_language", "original_language", "origin_countries"],
UseFieldOr = true, Relation = "contains", UseOr = true, Values = ["fi", "sv"] }, new()],
FieldActionLists = new() { ["title"] = ["Language:FI", "Language:de", "Original"], ["overview"] = [] }
}]
};
UserRulesNormalizer.Normalize(rules, ["fi"]);
var category = Assert.Single(rules.Categories);
Assert.False(category.MatchAllConditions);
Assert.Equal(["M", "C"], category.Scopes);
var condition = Assert.Single(category.Requirements);
Assert.Equal(["original_language", "audio_language"], condition.Fields);
Assert.True(condition.UseFieldOr);
Assert.True(condition.UseOr);
Assert.Equal(["Language:fi", "Original", "Jellyfin"], category.FieldActionLists["title"]);
Assert.Empty(category.FieldActionLists["overview"]);
Assert.Equal(JsonSerializer.Serialize(rules), JsonSerializer.Serialize(UserRulesNormalizer.Normalize(rules, ["fi"])));
}
[Fact]
public void NullExternalCollectionsNormalizeToDefaults()
{
var rules = JsonSerializer.Deserialize<UserRulesDocument>("""{"Categories":null,"FallbackFieldActions":null}""")!;
UserRulesNormalizer.Normalize(rules);
Assert.Empty(rules.Categories);
Assert.Equal(["Jellyfin"], rules.FallbackFieldActions["poster"]);
}
}