Add backup export and local artwork storage

This commit is contained in:
ajp_anton
2026-06-08 04:19:08 +00:00
parent 63dbc3a6d4
commit a4bb9a98c2
14 changed files with 1745 additions and 46 deletions
@@ -0,0 +1,138 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using Jellyfin.Plugin.Multilang.Data;
namespace Jellyfin.Plugin.Multilang.Services.Assets;
public sealed class AssetStorageService
{
private const long MaxAssetBytes = 50L * 1024L * 1024L;
private readonly IHttpClientFactory _httpClientFactory;
private readonly TranslationStore _store;
public AssetStorageService(IHttpClientFactory httpClientFactory, TranslationStore store)
{
_httpClientFactory = httpClientFactory;
_store = store;
}
public async Task<string?> StoreAsync(
string itemId,
string lang,
string kind,
string sourceUrl,
bool localStorage,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(sourceUrl))
return null;
if (!localStorage)
return sourceUrl;
var relative = BuildRelativePath(itemId, lang, kind, sourceUrl);
var expectedUrl = TranslationStore.LocalAssetUrlPrefix + relative.Replace('\\', '/');
if (_store.GetAssetPath(itemId, lang, kind) is { } existing &&
existing.Equals(expectedUrl, StringComparison.OrdinalIgnoreCase) &&
_store.TryNormalizeLocalAssetPath(existing, out var existingPath) &&
File.Exists(existingPath))
{
return existing;
}
var destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative));
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Resolved asset path escaped the Multilang assets directory.");
Directory.CreateDirectory(Path.GetDirectoryName(destination) ?? _store.AssetsDirectory);
await DownloadAsync(sourceUrl, destination, cancellationToken).ConfigureAwait(false);
return expectedUrl;
}
public bool TryResolveLocalAsset(string relativePath, out string fullPath, out string contentType)
{
fullPath = string.Empty;
contentType = "application/octet-stream";
var clean = (relativePath ?? string.Empty).TrimStart('/', '\\');
if (clean.Length == 0)
return false;
var candidate = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, clean));
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
return false;
fullPath = candidate;
contentType = ContentTypeFor(Path.GetExtension(candidate));
return true;
}
private async Task DownloadAsync(string sourceUrl, string destination, CancellationToken cancellationToken)
{
var http = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUrl);
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is > MaxAssetBytes)
throw new InvalidOperationException("Provider asset is larger than the allowed download size.");
var temp = destination + ".tmp";
var total = 0L;
await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var output = File.Create(temp))
{
var buffer = new byte[128 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
break;
total += read;
if (total > MaxAssetBytes)
throw new InvalidOperationException("Provider asset is larger than the allowed download size.");
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
}
}
File.Move(temp, destination, overwrite: true);
}
private static string BuildRelativePath(string itemId, string lang, string kind, string sourceUrl)
{
var extension = ExtensionFromUrl(sourceUrl);
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sourceUrl))).ToLowerInvariant()[..16];
return Path.Combine(SafeSegment(itemId), SafeSegment(lang), SafeSegment(kind) + "-" + hash + extension);
}
private static string SafeSegment(string value)
{
var chars = value
.Select(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_')
.ToArray();
var result = new string(chars).Trim('_');
return result.Length == 0 ? "unknown" : result;
}
private static string ExtensionFromUrl(string sourceUrl)
{
if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out var uri))
return ".bin";
var ext = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant();
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" ? ext : ".bin";
}
private static string ContentTypeFor(string extension)
=> extension.ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
_ => MediaTypeHeaderValue.Parse("application/octet-stream").MediaType ?? "application/octet-stream"
};
}
@@ -0,0 +1,438 @@
using System.Globalization;
using System.IO.Compression;
using System.Text.Json;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.Services.Backup;
public sealed class MultilangBackupService
{
private const string ManifestName = "manifest.json";
private const string PluginConfigName = "plugin-config.json";
private const string DatabaseName = "translations.sqlite";
private const string UserRulesPrefix = "user-rules/";
private const string AssetsPrefix = "assets/";
private readonly TranslationStore _store;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly ILogger<MultilangBackupService> _logger;
public MultilangBackupService(
TranslationStore store,
ILibraryManager libraryManager,
IUserManager userManager,
ILogger<MultilangBackupService> logger)
{
_store = store;
_libraryManager = libraryManager;
_userManager = userManager;
_logger = logger;
}
public BackupStorageInfo GetStorageInfo()
{
var (databaseBytes, assetsBytes) = _store.GetStorageUsage();
return new BackupStorageInfo(databaseBytes, assetsBytes);
}
public byte[] Export(BackupExportOptions options, PluginConfiguration configuration)
=> Export(options, configuration, null);
public byte[] ExportUserRules(string userId)
=> Export(new BackupExportOptions(false, true, false, false), new PluginConfiguration(), NormalizeUserId(userId));
public byte[] ExportUserRules(string userId, UserRulesDocument rules)
{
var normalized = NormalizeUserId(userId) ?? userId;
var tempDir = CreateTempDir();
try
{
var zipPath = Path.Combine(tempDir, "multilang-user-export.zip");
using (var file = File.Create(zipPath))
using (var zip = new ZipArchive(file, ZipArchiveMode.Create))
{
AddJson(zip, UserRulesPrefix + normalized + ".json", new BackupUserRules(normalized, rules), CompressionLevel.Optimal);
AddJson(zip, ManifestName, new BackupManifest(
"Jellyfin.Plugin.Multilang.Export",
1,
DateTimeOffset.UtcNow,
["user-settings"]), CompressionLevel.Optimal);
}
return File.ReadAllBytes(zipPath);
}
finally
{
TryDeleteDirectory(tempDir);
}
}
private byte[] Export(BackupExportOptions options, PluginConfiguration configuration, string? onlyUserId)
{
var tempDir = CreateTempDir();
try
{
var sections = new List<string>();
var zipPath = Path.Combine(tempDir, "multilang-export.zip");
using (var file = File.Create(zipPath))
using (var zip = new ZipArchive(file, ZipArchiveMode.Create))
{
if (options.PluginSettings)
{
AddJson(zip, PluginConfigName, configuration, CompressionLevel.Optimal);
sections.Add("plugin-settings");
}
if (options.UserSettings)
{
foreach (var user in _store.GetAllUserRules().Where(user => onlyUserId is null || user.Key.Equals(onlyUserId, StringComparison.OrdinalIgnoreCase)))
{
AddJson(zip, UserRulesPrefix + user.Key + ".json", new BackupUserRules(user.Key, user.Value), CompressionLevel.Optimal);
}
sections.Add("user-settings");
}
if (options.TranslationsDatabase)
{
var dbPath = Path.Combine(tempDir, DatabaseName);
_store.ExportTranslationDatabase(dbPath);
zip.CreateEntryFromFile(dbPath, DatabaseName, CompressionLevel.Optimal);
sections.Add("translations-database");
}
if (options.DownloadedAssets)
{
AddAssets(zip);
sections.Add("downloaded-assets");
}
AddJson(zip, ManifestName, new BackupManifest(
"Jellyfin.Plugin.Multilang.Export",
1,
DateTimeOffset.UtcNow,
sections.ToArray()), CompressionLevel.Optimal);
}
return File.ReadAllBytes(zipPath);
}
finally
{
TryDeleteDirectory(tempDir);
}
}
public BackupImportResult ImportAdmin(
Stream stream,
BackupImportOptions options,
PluginConfiguration currentConfiguration,
Action<PluginConfiguration> saveConfiguration)
{
if (options.DownloadedAssets && !options.TranslationsDatabase)
throw new InvalidOperationException("Downloaded assets can only be imported together with the translations database.");
var tempDir = CreateTempDir();
try
{
var zipPath = Path.Combine(tempDir, "import.zip");
using (var file = File.Create(zipPath))
stream.CopyTo(file);
using var zip = ZipFile.OpenRead(zipPath);
ValidateManifest(zip);
var importedConfig = false;
var importedUsers = 0;
var ignoredUsers = 0;
ImportDatabaseResult database = default;
var importedAssetFiles = 0;
var deletedUnreferencedAssets = 0;
if (options.PluginSettings)
{
var cfg = ReadJsonEntry<PluginConfiguration>(zip, PluginConfigName);
if (cfg is not null)
{
saveConfiguration(cfg);
importedConfig = true;
currentConfiguration = cfg;
}
}
if (options.UserSettings)
{
var existingUserIds = GetExistingUserIds();
foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith(UserRulesPrefix, StringComparison.OrdinalIgnoreCase) && e.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)))
{
var payload = ReadJsonEntry<BackupUserRules>(entry);
var userId = NormalizeUserId(payload?.UserId ?? Path.GetFileNameWithoutExtension(entry.FullName));
if (userId is null || !existingUserIds.Contains(userId) || payload?.Rules is null)
{
ignoredUsers++;
continue;
}
_store.SaveUserRules(userId, payload.Rules);
importedUsers++;
}
}
if (options.TranslationsDatabase)
{
var dbEntry = zip.GetEntry(DatabaseName);
if (dbEntry is not null)
{
var dbPath = Path.Combine(tempDir, DatabaseName);
dbEntry.ExtractToFile(dbPath, overwrite: true);
database = _store.ImportTranslationDatabase(dbPath, GetLiveItemIds(), GetAllowedLanguages(currentConfiguration));
}
}
if (options.DownloadedAssets)
{
importedAssetFiles = ImportAssetFiles(zip);
deletedUnreferencedAssets = _store.CleanupUnreferencedAssetFiles();
}
return new BackupImportResult(
importedConfig,
importedUsers,
ignoredUsers,
database.Facts,
database.Translations,
database.Assets,
database.Genres,
importedAssetFiles,
deletedUnreferencedAssets,
[],
false);
}
finally
{
TryDeleteDirectory(tempDir);
}
}
public UserBackupInspectResult InspectUserImport(Stream stream, string currentUserId)
{
var tempDir = CreateTempDir();
try
{
var zipPath = Path.Combine(tempDir, "import.zip");
using (var file = File.Create(zipPath))
stream.CopyTo(file);
using var zip = ZipFile.OpenRead(zipPath);
ValidateManifest(zip);
var userIds = GetUserRuleIds(zip);
var normalizedCurrent = NormalizeUserId(currentUserId) ?? currentUserId;
return new UserBackupInspectResult(
userIds.Length,
userIds.Contains(normalizedCurrent, StringComparer.OrdinalIgnoreCase),
userIds);
}
finally
{
TryDeleteDirectory(tempDir);
}
}
public BackupImportResult ImportUser(Stream stream, string currentUserId, string? selectedSourceUserId)
{
var tempDir = CreateTempDir();
try
{
var zipPath = Path.Combine(tempDir, "import.zip");
using (var file = File.Create(zipPath))
stream.CopyTo(file);
using var zip = ZipFile.OpenRead(zipPath);
ValidateManifest(zip);
var current = NormalizeUserId(currentUserId) ?? currentUserId;
var ids = GetUserRuleIds(zip);
if (ids.Length == 0)
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, [], false);
var source = ids.FirstOrDefault(id => id.Equals(current, StringComparison.OrdinalIgnoreCase));
if (source is null && !string.IsNullOrWhiteSpace(selectedSourceUserId))
{
var selected = NormalizeUserId(selectedSourceUserId) ?? selectedSourceUserId.Trim();
source = ids.FirstOrDefault(id => id.Equals(selected, StringComparison.OrdinalIgnoreCase));
}
if (source is null)
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, ids, true);
var payload = ReadJsonEntry<BackupUserRules>(zip, UserRulesPrefix + source + ".json");
if (payload?.Rules is null)
return new BackupImportResult(false, 0, 0, 0, 0, 0, 0, 0, 0, [], false);
_store.SaveUserRules(current, payload.Rules);
return new BackupImportResult(false, 1, 0, 0, 0, 0, 0, 0, 0, [], false);
}
finally
{
TryDeleteDirectory(tempDir);
}
}
public int CleanupUnreferencedAssetFiles()
=> _store.CleanupUnreferencedAssetFiles();
private void AddAssets(ZipArchive zip)
{
if (!Directory.Exists(_store.AssetsDirectory))
return;
foreach (var file in Directory.EnumerateFiles(_store.AssetsDirectory, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(_store.AssetsDirectory, file).Replace('\\', '/');
zip.CreateEntryFromFile(file, AssetsPrefix + relative, CompressionLevel.NoCompression);
}
}
private int ImportAssetFiles(ZipArchive zip)
{
var count = 0;
Directory.CreateDirectory(_store.AssetsDirectory);
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
foreach (var entry in zip.Entries.Where(e => e.FullName.StartsWith(AssetsPrefix, StringComparison.OrdinalIgnoreCase) && !e.FullName.EndsWith("/", StringComparison.Ordinal)))
{
var relative = entry.FullName[AssetsPrefix.Length..].Replace('/', Path.DirectorySeparatorChar);
var destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative));
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
continue;
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
entry.ExtractToFile(destination, overwrite: true);
count++;
}
return count;
}
private HashSet<string> GetLiveItemIds()
{
var query = new InternalItemsQuery
{
IncludeItemTypes =
[
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.BoxSet,
BaseItemKind.Season,
BaseItemKind.Episode
],
Recursive = true
};
return _libraryManager.GetItemList(query)
.Select(item => TranslationStore.ToItemId32(item.Id))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private HashSet<string> GetExistingUserIds()
=> _userManager.GetUsersIds().Select(id => id.ToString("N")).ToHashSet(StringComparer.OrdinalIgnoreCase);
private static HashSet<string> GetAllowedLanguages(PluginConfiguration configuration)
=> (configuration.Languages ?? []).Where(l => !string.IsNullOrWhiteSpace(l)).ToHashSet(StringComparer.OrdinalIgnoreCase);
private static string[] GetUserRuleIds(ZipArchive zip)
=> zip.Entries
.Where(e => e.FullName.StartsWith(UserRulesPrefix, StringComparison.OrdinalIgnoreCase) && e.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
.Select(e => NormalizeUserId(Path.GetFileNameWithoutExtension(e.FullName)))
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
private static string? NormalizeUserId(string? userId)
{
userId = (userId ?? string.Empty).Trim();
if (Guid.TryParse(userId, out var guid))
return guid.ToString("N");
return userId.Length == 32 && userId.All(Uri.IsHexDigit)
? userId.ToLowerInvariant()
: null;
}
private static void AddJson<T>(ZipArchive zip, string path, T value, CompressionLevel compressionLevel)
{
var entry = zip.CreateEntry(path, compressionLevel);
using var stream = entry.Open();
JsonSerializer.Serialize(stream, value, new JsonSerializerOptions { WriteIndented = true });
}
private static T? ReadJsonEntry<T>(ZipArchive zip, string path)
{
var entry = zip.GetEntry(path);
return entry is null ? default : ReadJsonEntry<T>(entry);
}
private static T? ReadJsonEntry<T>(ZipArchiveEntry entry)
{
using var stream = entry.Open();
return JsonSerializer.Deserialize<T>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
private static void ValidateManifest(ZipArchive zip)
{
var manifest = ReadJsonEntry<BackupManifest>(zip, ManifestName);
if (manifest is null || !manifest.Format.Equals("Jellyfin.Plugin.Multilang.Export", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("This is not a Multilang export file.");
}
private static string CreateTempDir()
{
var path = Path.Combine(Path.GetTempPath(), "multilang-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
Directory.CreateDirectory(path);
return path;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
}
public sealed record BackupExportOptions(bool PluginSettings, bool UserSettings, bool TranslationsDatabase, bool DownloadedAssets);
public sealed record BackupImportOptions(bool PluginSettings, bool UserSettings, bool TranslationsDatabase, bool DownloadedAssets);
public sealed record BackupStorageInfo(long DatabaseBytes, long AssetsBytes);
public sealed record BackupManifest(string Format, int Version, DateTimeOffset CreatedUtc, string[] Sections);
public sealed record BackupUserRules(string UserId, UserRulesDocument Rules);
public sealed record UserBackupInspectResult(int UserSettingsCount, bool ContainsCurrentUser, string[] UserIds);
public sealed record BackupImportResult(
bool PluginSettingsImported,
int UserSettingsImported,
int UserSettingsIgnored,
int FactsImported,
int TranslationsImported,
int AssetsImported,
int GenresImported,
int AssetFilesImported,
int AssetFilesDeleted,
string[] CandidateUserIds,
bool NeedsUserSelection);
@@ -38,6 +38,24 @@ internal static class WebScriptInjector
return true;
}
public static bool RemoveInjected(string webRootPath)
{
if (string.IsNullOrWhiteSpace(webRootPath))
return false;
var indexPath = Path.Combine(webRootPath, "index.html");
if (!File.Exists(indexPath))
return false;
var content = File.ReadAllText(indexPath);
var cleaned = RemoveOldBlock(content);
if (cleaned.Equals(content, StringComparison.Ordinal))
return false;
File.WriteAllText(indexPath, cleaned, Encoding.UTF8);
return true;
}
private static string RemoveOldBlock(string content)
{
var start = content.IndexOf(StartComment, StringComparison.Ordinal);
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Assets;
using Jellyfin.Plugin.Multilang.Services.Providers;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -18,6 +19,7 @@ public sealed class RefreshService
private readonly TranslationStore _store;
private readonly TmdbClient _tmdbClient;
private readonly FanartClient _fanartClient;
private readonly AssetStorageService _assetStorage;
private readonly RefreshCoordinator _coordinator;
private readonly ILogger<RefreshService> _logger;
private readonly object _diagnosticsLock = new();
@@ -28,6 +30,7 @@ public sealed class RefreshService
TranslationStore store,
TmdbClient tmdbClient,
FanartClient fanartClient,
AssetStorageService assetStorage,
RefreshCoordinator coordinator,
ILogger<RefreshService> logger)
{
@@ -35,6 +38,7 @@ public sealed class RefreshService
_store = store;
_tmdbClient = tmdbClient;
_fanartClient = fanartClient;
_assetStorage = assetStorage;
_coordinator = coordinator;
_logger = logger;
}
@@ -156,7 +160,19 @@ public sealed class RefreshService
var trackedBefore = _store.GetTrackedItemIds();
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count();
_store.CleanupNotInLibrary(liveIds);
// Run local housekeeping only from full-library scans. ItemsProxy browsing may enqueue
// missing items, but it must not delete local assets just because the admin briefly
// changed artwork storage mode.
var localCleanup = _store.CleanupForConfiguration(liveIds, NormalizeLanguages(cfg.Languages), IsLocalAssetStorage(cfg));
if (localCleanup.TranslationsDeleted > 0 || localCleanup.AssetsDeleted > 0 || localCleanup.GenresDeleted > 0 || localCleanup.AssetFilesDeleted > 0)
{
_logger.LogInformation(
"Multilang local cleanup translations={Translations} assets={Assets} genres={Genres} assetFiles={AssetFiles}",
localCleanup.TranslationsDeleted,
localCleanup.AssetsDeleted,
localCleanup.GenresDeleted,
localCleanup.AssetFilesDeleted);
}
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
var index = 0;
@@ -168,9 +184,10 @@ public sealed class RefreshService
SetCurrent(item, index, "checking");
var status = _store.GetFactsStatus(item.ItemId);
var isNew = !status.Exists;
var missingConfiguredData = jobType == RefreshJobType.Missing && status.Exists && ItemNeedsMissingRefresh(item, cfg, langs: null);
var due = jobType == RefreshJobType.Full
? !status.Exists || status.FullCheckedAt < fullCutoff
: !status.Exists || status.MissingCheckedAt < missingCutoff;
: !status.Exists || status.MissingCheckedAt < missingCutoff || missingConfiguredData;
if (!due)
{
@@ -304,6 +321,8 @@ public sealed class RefreshService
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
{
// Browsing can only backfill completely untracked items. It deliberately does not run
// local cleanup or convert existing local assets to URL rows.
var cfg = Plugin.Instance?.Configuration;
if (cfg is null)
return false;
@@ -530,9 +549,9 @@ public sealed class RefreshService
if (tmdbImages is null)
continue;
StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written);
StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written);
StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written);
await StoreImageMapAsync(item.ItemId, languages, "poster", tmdbImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, "logo", tmdbImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
continue;
}
@@ -543,22 +562,24 @@ public sealed class RefreshService
if (fanartImages is null)
continue;
StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written);
StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written);
StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written);
StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written);
await StoreImageMapAsync(item.ItemId, languages, "poster", fanartImages.Posters, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, "logo", fanartImages.Logos, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, "banner", fanartImages.Banners, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
await StoreImageMapAsync(item.ItemId, languages, "thumb", fanartImages.Thumbs, cfg, updatedAt, written, cancellationToken).ConfigureAwait(false);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
}
}
}
private void StoreImageMap(
private async Task StoreImageMapAsync(
string itemId,
IEnumerable<string> languages,
string kind,
IReadOnlyDictionary<string, string> images,
PluginConfiguration cfg,
long updatedAt,
HashSet<string> written)
HashSet<string> written,
CancellationToken cancellationToken)
{
foreach (var lang in languages)
{
@@ -573,11 +594,25 @@ public sealed class RefreshService
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
continue;
_store.UpsertAsset(itemId, storeLang, kind, url, updatedAt);
var storedPath = await _assetStorage.StoreAsync(itemId, storeLang, kind, url, IsLocalAssetStorage(cfg), cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(storedPath))
continue;
_store.UpsertAsset(itemId, storeLang, kind, storedPath, updatedAt);
written.Add(key);
}
}
private bool ItemNeedsMissingRefresh(RefreshItemInfo item, PluginConfiguration cfg, string[]? langs)
{
langs ??= NormalizeLanguages(cfg.Languages);
if (_store.HasMissingConfiguredTranslations(item.ItemId, langs))
return true;
var assetPresence = _store.GetAssetPresence(item.ItemId, langs.Append(OriginalLanguageTag));
return IsLocalAssetStorage(cfg) && assetPresence.AnyRemote;
}
private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
=> languages
.Concat([OriginalLanguageTag + ":" + originalLanguage])
@@ -743,6 +778,9 @@ public sealed class RefreshService
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static bool IsLocalAssetStorage(PluginConfiguration cfg)
=> string.Equals(cfg.AssetStorageMode, "local", StringComparison.OrdinalIgnoreCase);
private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
}