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