Expand integration coverage and fix settings UI
This commit is contained in:
@@ -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="12.0.0-rc2" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0-rc2" />
|
||||
<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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user