Files
jellyfin-multilang/tests/Jellyfin.Plugin.Multilang.Tests/ItemFetcherTests.cs
T
ajp_anton 096c6be8c7 Release 0.3.4: Jellyfin 12 stable support and audit fixes
Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. 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);
}