787 lines
33 KiB
C#
787 lines
33 KiB
C#
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;
|
|
using MediaBrowser.Model.Querying;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
|
|
|
|
public sealed class RefreshService
|
|
{
|
|
private const string OriginalLanguageTag = "Original";
|
|
|
|
private readonly ILibraryManager _libraryManager;
|
|
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();
|
|
private RefreshScanDiagnostics _scanDiagnostics = EmptyScanDiagnostics();
|
|
|
|
public RefreshService(
|
|
ILibraryManager libraryManager,
|
|
TranslationStore store,
|
|
TmdbClient tmdbClient,
|
|
FanartClient fanartClient,
|
|
AssetStorageService assetStorage,
|
|
RefreshCoordinator coordinator,
|
|
ILogger<RefreshService> logger)
|
|
{
|
|
_libraryManager = libraryManager;
|
|
_store = store;
|
|
_tmdbClient = tmdbClient;
|
|
_fanartClient = fanartClient;
|
|
_assetStorage = assetStorage;
|
|
_coordinator = coordinator;
|
|
_logger = logger;
|
|
}
|
|
|
|
public RefreshDiagnostics GetDiagnostics()
|
|
=> new(
|
|
_store.GetLastScanStarted(),
|
|
GetScanDiagnostics(),
|
|
_coordinator.GetDiagnostics(),
|
|
ProviderHttp.GetDiagnostics());
|
|
|
|
private RefreshScanDiagnostics GetScanDiagnostics()
|
|
{
|
|
lock (_diagnosticsLock)
|
|
return _scanDiagnostics;
|
|
}
|
|
|
|
private void SetScanDiagnostics(RefreshScanDiagnostics diagnostics)
|
|
{
|
|
lock (_diagnosticsLock)
|
|
_scanDiagnostics = diagnostics;
|
|
}
|
|
|
|
private static RefreshScanDiagnostics EmptyScanDiagnostics()
|
|
=> new(false, string.Empty, string.Empty, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, string.Empty, string.Empty, string.Empty, string.Empty, true, string.Empty);
|
|
|
|
public async Task RunScanAsync(RefreshJobType jobType, IProgress<double> progress, CancellationToken cancellationToken, string runSource)
|
|
{
|
|
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
|
|
ValidateRefreshConfiguration(cfg);
|
|
await _coordinator.ScanMutex.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
var startedAt = TranslationStore.NowUnixUtc();
|
|
var sw = Stopwatch.StartNew();
|
|
var mode = jobType.ToString();
|
|
var source = runSource ?? string.Empty;
|
|
var itemCount = 0;
|
|
var dueCount = 0;
|
|
var refreshedCount = 0;
|
|
var skippedNotDueCount = 0;
|
|
var skippedNoDataCount = 0;
|
|
var newCount = 0;
|
|
var deletedCount = 0;
|
|
var currentIndex = 0;
|
|
var currentItemId = string.Empty;
|
|
var currentName = string.Empty;
|
|
var currentKind = string.Empty;
|
|
var currentAction = "initializing";
|
|
|
|
void UpdateRunningScan(string action = "")
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(action))
|
|
currentAction = action;
|
|
|
|
SetScanDiagnostics(new RefreshScanDiagnostics(
|
|
true,
|
|
mode,
|
|
source,
|
|
startedAt,
|
|
0,
|
|
sw.ElapsedMilliseconds,
|
|
itemCount,
|
|
dueCount,
|
|
refreshedCount,
|
|
skippedNotDueCount,
|
|
skippedNoDataCount,
|
|
newCount,
|
|
deletedCount,
|
|
currentIndex,
|
|
currentItemId,
|
|
currentName,
|
|
currentKind,
|
|
currentAction,
|
|
true,
|
|
string.Empty));
|
|
}
|
|
|
|
void SetCurrent(RefreshItemInfo item, int index, string action)
|
|
{
|
|
currentIndex = index;
|
|
currentItemId = item.ItemId;
|
|
currentName = item.DisplayName;
|
|
currentKind = item.Kind;
|
|
currentAction = action;
|
|
}
|
|
|
|
void CompleteScan(bool success, string error)
|
|
=> SetScanDiagnostics(new RefreshScanDiagnostics(
|
|
false,
|
|
mode,
|
|
source,
|
|
startedAt,
|
|
TranslationStore.NowUnixUtc(),
|
|
sw.ElapsedMilliseconds,
|
|
itemCount,
|
|
dueCount,
|
|
refreshedCount,
|
|
skippedNotDueCount,
|
|
skippedNoDataCount,
|
|
newCount,
|
|
deletedCount,
|
|
currentIndex,
|
|
currentItemId,
|
|
currentName,
|
|
currentKind,
|
|
success ? "complete" : "failed",
|
|
success,
|
|
error));
|
|
|
|
try
|
|
{
|
|
UpdateRunningScan();
|
|
var items = GetAllItemsWithTmdbId();
|
|
var now = TranslationStore.NowUnixUtc();
|
|
var missingCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForMissingData)).TotalSeconds;
|
|
var fullCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForExistingData)).TotalSeconds;
|
|
var ordered = items.OrderBy(i => i.WorkClass).ToList();
|
|
itemCount = ordered.Count;
|
|
var liveIds = ordered.Select(i => i.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var trackedBefore = _store.GetTrackedItemIds();
|
|
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
|
|
newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count();
|
|
// 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;
|
|
UpdateRunningScan();
|
|
foreach (var item in ordered)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
index++;
|
|
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 || missingConfiguredData;
|
|
|
|
if (!due)
|
|
{
|
|
skippedNotDueCount++;
|
|
if (cfg.VerboseLogging && cfg.EnableLogging)
|
|
{
|
|
_logger.LogInformation(
|
|
"Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due",
|
|
index,
|
|
ordered.Count,
|
|
item.ItemId,
|
|
item.DisplayName,
|
|
item.Kind);
|
|
}
|
|
|
|
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
|
|
UpdateRunningScan("skipped-not-due");
|
|
continue;
|
|
}
|
|
|
|
var scanIndex = index;
|
|
var actualJobType = jobType == RefreshJobType.Full || !status.Exists ? RefreshJobType.Full : RefreshJobType.Missing;
|
|
dueCount++;
|
|
SetCurrent(item, scanIndex, "fetching");
|
|
Log(
|
|
cfg.EnableLogging,
|
|
LogLevel.Information,
|
|
"Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId} mode={Mode} new={New}",
|
|
scanIndex,
|
|
ordered.Count,
|
|
item.ItemId,
|
|
item.DisplayName,
|
|
item.Kind,
|
|
item.TmdbId,
|
|
actualJobType,
|
|
isNew);
|
|
|
|
var ok = await _coordinator.EnqueueAsync(
|
|
item.ItemId,
|
|
RefreshSourceTier.Background,
|
|
item.WorkClass,
|
|
actualJobType,
|
|
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, ordered.Count, ct).ConfigureAwait(false),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (ok)
|
|
refreshedCount++;
|
|
else
|
|
skippedNoDataCount++;
|
|
|
|
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
|
|
UpdateRunningScan(ok ? "refreshed" : "skipped-no-data");
|
|
}
|
|
|
|
sw.Stop();
|
|
_store.SetLastScanStarted(now);
|
|
CompleteScan(true, string.Empty);
|
|
_logger.LogInformation(
|
|
"Multilang refresh complete mode={Mode} source={Source} elapsed={ElapsedMs}ms items={Items} due={Due} refreshed={Refreshed} skippedNotDue={SkippedNotDue} skippedNoData={SkippedNoData} new={New} deleted={Deleted}",
|
|
jobType,
|
|
runSource,
|
|
sw.ElapsedMilliseconds,
|
|
ordered.Count,
|
|
dueCount,
|
|
refreshedCount,
|
|
skippedNotDueCount,
|
|
skippedNoDataCount,
|
|
newCount,
|
|
deletedCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
CompleteScan(false, ex.Message);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_coordinator.ScanMutex.Release();
|
|
}
|
|
}
|
|
|
|
public async Task RefreshItemAsync(Guid itemId, bool includeChildren, CancellationToken cancellationToken)
|
|
{
|
|
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
|
|
ValidateRefreshConfiguration(cfg);
|
|
var root = _libraryManager.GetItemById(itemId);
|
|
if (root is null)
|
|
{
|
|
Log(cfg.EnableLogging, LogLevel.Warning, "Manual refresh requested for missing item {ItemId}", itemId);
|
|
return;
|
|
}
|
|
|
|
var items = BuildItemInfos(GetItemAndChildren(root, includeChildren)).ToList();
|
|
var total = items.Count;
|
|
var index = 0;
|
|
_logger.LogInformation("Manual Multilang refresh started item={ItemId} name={Name} items={Items} includeChildren={IncludeChildren}", itemId, root.Name ?? string.Empty, total, includeChildren);
|
|
foreach (var item in items)
|
|
{
|
|
index++;
|
|
var scanIndex = index;
|
|
Log(
|
|
cfg.EnableLogging,
|
|
LogLevel.Information,
|
|
"Manual Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}",
|
|
scanIndex,
|
|
total,
|
|
item.ItemId,
|
|
item.DisplayName,
|
|
item.Kind,
|
|
item.TmdbId);
|
|
|
|
var ok = await _coordinator.EnqueueAsync(
|
|
item.ItemId,
|
|
RefreshSourceTier.Manual,
|
|
item.WorkClass,
|
|
RefreshJobType.Full,
|
|
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, total, ct).ConfigureAwait(false),
|
|
cancellationToken).ConfigureAwait(false);
|
|
Log(
|
|
cfg.EnableLogging,
|
|
ok ? LogLevel.Information : LogLevel.Warning,
|
|
"Manual Multilang refresh item {Status} [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind}",
|
|
ok ? "complete" : "skipped",
|
|
scanIndex,
|
|
total,
|
|
item.ItemId,
|
|
item.DisplayName,
|
|
item.Kind);
|
|
}
|
|
}
|
|
|
|
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;
|
|
try
|
|
{
|
|
ValidateRefreshConfiguration(cfg);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var ids = itemIds
|
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
if (ids.Length == 0)
|
|
return false;
|
|
|
|
var existingFacts = _store.GetExistingFactsItemIds(ids);
|
|
var queuedAny = false;
|
|
var sourceTier = ids.Length <= 5 ? RefreshSourceTier.OnTheFly : RefreshSourceTier.Background;
|
|
foreach (var itemId in ids)
|
|
{
|
|
if (existingFacts.Contains(itemId))
|
|
continue;
|
|
if (!Guid.TryParseExact(itemId, "N", out var guid))
|
|
continue;
|
|
|
|
var item = _libraryManager.GetItemById(guid);
|
|
if (item is null)
|
|
continue;
|
|
|
|
var info = BuildItemInfos([item]).FirstOrDefault();
|
|
if (info is null)
|
|
continue;
|
|
|
|
queuedAny = true;
|
|
_ = _coordinator.EnqueueAsync(
|
|
info.ItemId,
|
|
sourceTier,
|
|
info.WorkClass,
|
|
RefreshJobType.Full,
|
|
async (actualJobType, ct) => await RefreshItemCoreAsync(info, cfg, actualJobType, 0, 0, ct).ConfigureAwait(false),
|
|
CancellationToken.None)
|
|
.ContinueWith(
|
|
task =>
|
|
{
|
|
if (task.Exception is not null)
|
|
_logger.LogWarning(task.Exception, "On-demand Multilang refresh failed for item={ItemId}", info.ItemId);
|
|
},
|
|
TaskContinuationOptions.OnlyOnFaulted);
|
|
_logger.LogInformation("Queued on-demand Multilang refresh item={ItemId} tier={Tier}", info.ItemId, sourceTier);
|
|
}
|
|
|
|
return queuedAny;
|
|
}
|
|
|
|
public RefreshItemInfo? GetRefreshItemInfo(Guid itemId)
|
|
{
|
|
var item = _libraryManager.GetItemById(itemId);
|
|
return item is null ? null : BuildItemInfos([item]).FirstOrDefault();
|
|
}
|
|
|
|
private async Task<bool> RefreshItemCoreAsync(
|
|
RefreshItemInfo item,
|
|
PluginConfiguration cfg,
|
|
RefreshJobType jobType,
|
|
int scanIndex,
|
|
int totalItems,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var langs = NormalizeLanguages(cfg.Languages);
|
|
if (langs.Length == 0)
|
|
throw new InvalidOperationException("No Multilang languages are configured.");
|
|
|
|
if (cfg.VerboseLogging && cfg.EnableLogging)
|
|
_logger.LogInformation("[{Index}/{Total}] Fetching item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.Kind, item.TmdbId);
|
|
|
|
var now = TranslationStore.NowUnixUtc();
|
|
var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(250));
|
|
var wroteAny = false;
|
|
TmdbMetadata? firstMeta = null;
|
|
var fetchedMetadata = new Dictionary<string, TmdbMetadata>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var lang in langs)
|
|
{
|
|
var meta = await _tmdbClient.FetchMetadataAsync(item, lang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
|
|
if (meta is null)
|
|
{
|
|
Log(cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb 404, skipping item={ItemId} name=\"{Name}\" tmdb={TmdbId} kind={Kind}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.TmdbId, item.Kind);
|
|
_store.MarkFactsChecked(item, now, jobType == RefreshJobType.Full ? now : 0);
|
|
return false;
|
|
}
|
|
|
|
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb metadata ok item={ItemId} lang={Lang}", scanIndex, totalItems, item.ItemId, lang);
|
|
_store.UpsertTranslation(item.ItemId, lang, "title", meta.Title);
|
|
_store.UpsertTranslation(item.ItemId, lang, "overview", meta.Overview);
|
|
_store.UpsertTranslation(item.ItemId, lang, "tagline", meta.Tagline);
|
|
StoreGenres(item, lang, meta);
|
|
fetchedMetadata[lang] = meta;
|
|
firstMeta ??= meta;
|
|
if (!wroteAny)
|
|
{
|
|
_store.UpsertFacts(item, meta, now, jobType == RefreshJobType.Full ? now : 0);
|
|
wroteAny = true;
|
|
}
|
|
}
|
|
|
|
var artworkLanguages = langs;
|
|
if (firstMeta is not null && !string.IsNullOrWhiteSpace(firstMeta.OriginalLanguage))
|
|
{
|
|
var originalLang = firstMeta.OriginalLanguage.Trim();
|
|
var existingLang = langs.FirstOrDefault(lang => SameLanguageBase(lang, originalLang));
|
|
Log(
|
|
cfg.EnableLogging,
|
|
LogLevel.Information,
|
|
"[{Index}/{Total}] Original language item={ItemId} lang={OriginalLang} metadataSource={Source}",
|
|
scanIndex,
|
|
totalItems,
|
|
item.ItemId,
|
|
originalLang,
|
|
existingLang is not null ? "configured-language:" + existingLang : "extra-fetch");
|
|
var originalMeta = existingLang is not null
|
|
? fetchedMetadata[existingLang]
|
|
: await _tmdbClient.FetchMetadataAsync(item, originalLang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (originalMeta is not null)
|
|
{
|
|
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "title", originalMeta.Title);
|
|
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "overview", originalMeta.Overview);
|
|
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "tagline", originalMeta.Tagline);
|
|
StoreGenres(item, OriginalLanguageTag, originalMeta);
|
|
Log(
|
|
cfg.VerboseLogging && cfg.EnableLogging,
|
|
LogLevel.Information,
|
|
"[{Index}/{Total}] Original metadata stored item={ItemId} lang={OriginalLang} titlePresent={TitlePresent} overviewPresent={OverviewPresent} taglinePresent={TaglinePresent}",
|
|
scanIndex,
|
|
totalItems,
|
|
item.ItemId,
|
|
originalLang,
|
|
!string.IsNullOrWhiteSpace(originalMeta.Title),
|
|
!string.IsNullOrWhiteSpace(originalMeta.Overview),
|
|
!string.IsNullOrWhiteSpace(originalMeta.Tagline));
|
|
}
|
|
else
|
|
{
|
|
Log(
|
|
cfg.EnableLogging,
|
|
LogLevel.Warning,
|
|
"[{Index}/{Total}] Original metadata unavailable item={ItemId} lang={OriginalLang}",
|
|
scanIndex,
|
|
totalItems,
|
|
item.ItemId,
|
|
originalLang);
|
|
}
|
|
|
|
artworkLanguages = AppendOriginalLanguage(langs, originalLang);
|
|
Log(
|
|
cfg.VerboseLogging && cfg.EnableLogging,
|
|
LogLevel.Information,
|
|
"[{Index}/{Total}] Artwork language set item={ItemId} langs={Languages}",
|
|
scanIndex,
|
|
totalItems,
|
|
item.ItemId,
|
|
string.Join(",", artworkLanguages));
|
|
}
|
|
|
|
await StoreArtworkAsync(item, cfg, artworkLanguages, limiter, now, cancellationToken).ConfigureAwait(false);
|
|
|
|
Log(cfg.EnableLogging, LogLevel.Information, "Multilang refresh item complete item={ItemId} kind={Kind} tmdb={TmdbId} mode={Mode}", item.ItemId, item.Kind, item.TmdbId, jobType);
|
|
return true;
|
|
}
|
|
|
|
private void StoreGenres(RefreshItemInfo item, string lang, TmdbMetadata metadata)
|
|
{
|
|
var media = GenreMediaFor(item.Kind);
|
|
if (media is null)
|
|
return;
|
|
|
|
foreach (var genre in metadata.Genres)
|
|
_store.UpsertGenre(genre.Id, media, lang, genre.Name);
|
|
}
|
|
|
|
private static string? GenreMediaFor(string kind)
|
|
=> kind.Equals("movie", StringComparison.OrdinalIgnoreCase) ||
|
|
kind.Equals("collection", StringComparison.OrdinalIgnoreCase)
|
|
? "movie"
|
|
: kind.Equals("tv", StringComparison.OrdinalIgnoreCase) ||
|
|
kind.Equals("tvseason", StringComparison.OrdinalIgnoreCase) ||
|
|
kind.Equals("tvepisode", StringComparison.OrdinalIgnoreCase)
|
|
? "tv"
|
|
: null;
|
|
|
|
private async Task StoreArtworkAsync(
|
|
RefreshItemInfo item,
|
|
PluginConfiguration cfg,
|
|
string[] languages,
|
|
FetchRateLimiter limiter,
|
|
long updatedAt,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (item.Kind is "tvepisode")
|
|
return;
|
|
|
|
var providers = cfg.Providers
|
|
.Where(p => p.ArtworkOrder >= 0)
|
|
.OrderBy(p => p.ArtworkOrder)
|
|
.Select(p => p.Id.Trim().ToLowerInvariant())
|
|
.ToArray();
|
|
if (providers.Length == 0)
|
|
return;
|
|
|
|
TmdbImages? tmdbImages = null;
|
|
FanartImages? fanartImages = null;
|
|
var written = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var provider in providers)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (provider.Equals("tmdb", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
tmdbImages ??= await _tmdbClient.FetchImagesAsync(item, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
|
|
if (tmdbImages is null)
|
|
continue;
|
|
|
|
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;
|
|
}
|
|
|
|
if (provider.Equals("fanart", StringComparison.OrdinalIgnoreCase) && item.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
fanartImages ??= await _fanartClient.FetchMovieImagesAsync(item.TmdbId, cfg.FanartApiKey, cancellationToken).ConfigureAwait(false);
|
|
if (fanartImages is null)
|
|
continue;
|
|
|
|
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 async Task StoreImageMapAsync(
|
|
string itemId,
|
|
IEnumerable<string> languages,
|
|
string kind,
|
|
IReadOnlyDictionary<string, string> images,
|
|
PluginConfiguration cfg,
|
|
long updatedAt,
|
|
HashSet<string> written,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
foreach (var lang in languages)
|
|
{
|
|
var storeLang = lang.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase)
|
|
? OriginalLanguageTag
|
|
: lang;
|
|
var key = kind + ":" + storeLang;
|
|
if (written.Contains(key))
|
|
continue;
|
|
|
|
var iso = ToIso639(lang);
|
|
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
|
|
continue;
|
|
|
|
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])
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
private static bool SameLanguageBase(string left, string right)
|
|
=> ToIso639(left).Equals(ToIso639(right), StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string ToIso639(string language)
|
|
{
|
|
if (language.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase))
|
|
language = language[(OriginalLanguageTag.Length + 1)..];
|
|
var index = language.IndexOf('-', StringComparison.Ordinal);
|
|
return (index > 0 ? language[..index] : language).Trim().ToLowerInvariant();
|
|
}
|
|
|
|
private List<RefreshItemInfo> GetAllItemsWithTmdbId()
|
|
{
|
|
var query = new InternalItemsQuery
|
|
{
|
|
IncludeItemTypes =
|
|
[
|
|
BaseItemKind.Movie,
|
|
BaseItemKind.Series,
|
|
BaseItemKind.BoxSet,
|
|
BaseItemKind.Season,
|
|
BaseItemKind.Episode
|
|
],
|
|
Recursive = true
|
|
};
|
|
|
|
return BuildItemInfos(_libraryManager.GetItemList(query)).ToList();
|
|
}
|
|
|
|
private IEnumerable<RefreshItemInfo> BuildItemInfos(IEnumerable<BaseItem> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
var itemId = TranslationStore.ToItemId32(item.Id);
|
|
var typeName = item.GetType().Name;
|
|
|
|
if (typeName.Equals("Season", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out _))
|
|
yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvseason", RefreshWorkClass.Seasons, seasonNumber, 0, string.Empty, item.Name ?? string.Empty);
|
|
continue;
|
|
}
|
|
|
|
if (typeName.Equals("Episode", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out var episodeNumber))
|
|
yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvepisode", RefreshWorkClass.Episodes, seasonNumber, episodeNumber, string.Empty, item.Name ?? string.Empty);
|
|
continue;
|
|
}
|
|
|
|
var providerIds = item.ProviderIds ?? new Dictionary<string, string>();
|
|
providerIds.TryGetValue("Tmdb", out var tmdbId);
|
|
providerIds.TryGetValue("TmdbCollection", out var tmdbCollectionId);
|
|
|
|
if (typeName.Equals("Series", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(tmdbId))
|
|
{
|
|
yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "tv", RefreshWorkClass.Series, 0, 0, string.Empty, item.Name ?? string.Empty);
|
|
continue;
|
|
}
|
|
|
|
if (typeName.Equals("BoxSet", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var collectionId = FirstNonEmpty(tmdbCollectionId, tmdbId);
|
|
if (!string.IsNullOrWhiteSpace(collectionId))
|
|
yield return new RefreshItemInfo(itemId, collectionId.Trim(), "collection", RefreshWorkClass.Collections, 0, 0, string.Empty, item.Name ?? string.Empty);
|
|
continue;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(tmdbId))
|
|
yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "movie", RefreshWorkClass.Movies, 0, 0, string.Empty, item.Name ?? string.Empty);
|
|
}
|
|
}
|
|
|
|
private List<BaseItem> GetItemAndChildren(BaseItem root, bool includeChildren)
|
|
{
|
|
var items = new List<BaseItem> { root };
|
|
if (!includeChildren)
|
|
return items;
|
|
|
|
items.AddRange(_libraryManager.GetItemList(new InternalItemsQuery
|
|
{
|
|
ParentId = root.Id,
|
|
Recursive = true
|
|
}));
|
|
return items;
|
|
}
|
|
|
|
private bool TryResolveEpisodeContext(BaseItem item, out string seriesTmdbId, out int seasonNumber, out int episodeNumber)
|
|
{
|
|
seriesTmdbId = string.Empty;
|
|
seasonNumber = item.ParentIndexNumber ?? 0;
|
|
episodeNumber = item.IndexNumber ?? 0;
|
|
|
|
var typeName = item.GetType().Name;
|
|
var isSeason = typeName.Equals("Season", StringComparison.OrdinalIgnoreCase);
|
|
var seasonGuid = !isSeason ? TryGetGuid(item, "SeasonId") ?? (item.ParentId == Guid.Empty ? null : item.ParentId) : null;
|
|
var seriesGuid = TryGetGuid(item, "SeriesId") ?? (isSeason && item.ParentId != Guid.Empty ? item.ParentId : null);
|
|
var season = isSeason ? item : (seasonGuid.HasValue ? _libraryManager.GetItemById(seasonGuid.Value) : null);
|
|
|
|
if (!seriesGuid.HasValue && season is not null && season.ParentId != Guid.Empty)
|
|
seriesGuid = season.ParentId;
|
|
|
|
var series = seriesGuid.HasValue ? _libraryManager.GetItemById(seriesGuid.Value) : null;
|
|
if (isSeason)
|
|
{
|
|
seasonNumber = item.IndexNumber ?? 0;
|
|
}
|
|
|
|
if (seasonNumber <= 0 || (!isSeason && episodeNumber <= 0) || series is null)
|
|
return false;
|
|
|
|
var providerIds = series.ProviderIds ?? new Dictionary<string, string>();
|
|
if (!providerIds.TryGetValue("Tmdb", out var tmdbId) || string.IsNullOrWhiteSpace(tmdbId))
|
|
return false;
|
|
|
|
seriesTmdbId = tmdbId.Trim();
|
|
return true;
|
|
}
|
|
|
|
private static Guid? TryGetGuid(BaseItem item, string propertyName)
|
|
{
|
|
var property = item.GetType().GetProperty(propertyName);
|
|
var value = property?.GetValue(item);
|
|
return value switch
|
|
{
|
|
Guid guid when guid != Guid.Empty => guid,
|
|
string text when Guid.TryParse(text, out var guid) && guid != Guid.Empty => guid,
|
|
_ => (Guid?)null
|
|
};
|
|
}
|
|
|
|
private void Log(bool enabled, LogLevel level, string message, params object[] args)
|
|
{
|
|
if (enabled)
|
|
_logger.Log(level, message, args);
|
|
}
|
|
|
|
private static void ValidateRefreshConfiguration(PluginConfiguration cfg)
|
|
{
|
|
var metadataProviders = cfg.Providers
|
|
.Where(p => p.MetadataOrder >= 0)
|
|
.OrderBy(p => p.MetadataOrder)
|
|
.Select(p => p.Id)
|
|
.ToArray();
|
|
|
|
if (!metadataProviders.Contains("tmdb", StringComparer.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("TMDb must be enabled as a metadata provider before refresh can run.");
|
|
if (string.IsNullOrWhiteSpace(cfg.TmdbApiKey))
|
|
throw new InvalidOperationException("TMDb API key is required before refresh can run.");
|
|
if (NormalizeLanguages(cfg.Languages).Length == 0)
|
|
throw new InvalidOperationException("At least one Multilang language must be configured before refresh can run.");
|
|
}
|
|
|
|
private static string[] NormalizeLanguages(IEnumerable<string> languages)
|
|
=> languages.Select(l => (l ?? string.Empty).Trim())
|
|
.Where(l => l.Length > 0)
|
|
.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;
|
|
}
|