Files
jellyfin-multilang/tests/Jellyfin.Plugin.Multilang.Tests/ItemFetcherTests.cs
T
ajp_anton e36775f1a3 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.
2026-09-09 01:18:43 +00:00

233 lines
11 KiB
C#

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);
}