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(), backdrops = Array.Empty() }); 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() }); }); _fetcher = new(_store, new(_http), new(_http), new(_http, _store), NullLogger.Instance); } private static HttpResponseMessage Json(object value) => new(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(value)) }; private Task 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 { _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(() => 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 { _item.ItemId }, new HashSet { "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()); Assert.Throws(() => _store.SaveArtwork(_item.ItemId, [original with { Path = "new" }, original], new Dictionary())); 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 { "FI", "SE" }, JsonSerializer.Deserialize>(mixed.ProductionCountriesJson)); Assert.Equal("fi", Services.ItemsProxyTransformer.AggregateCollectionFacts(collection, [finnish, finnish]).OriginalLanguage); } public void Dispose() => TestPaths.DeleteRoot(_root); }