Initial jellyfin-multilang rewrite
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
using System.Diagnostics;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Multilang.Configuration;
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
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 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,
|
||||
RefreshCoordinator coordinator,
|
||||
ILogger<RefreshService> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_store = store;
|
||||
_tmdbClient = tmdbClient;
|
||||
_fanartClient = fanartClient;
|
||||
_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();
|
||||
_store.CleanupNotInLibrary(liveIds);
|
||||
|
||||
_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 due = jobType == RefreshJobType.Full
|
||||
? !status.Exists || status.FullCheckedAt < fullCutoff
|
||||
: !status.Exists || status.MissingCheckedAt < missingCutoff;
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
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);
|
||||
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;
|
||||
|
||||
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);
|
||||
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreImageMap(
|
||||
string itemId,
|
||||
IEnumerable<string> languages,
|
||||
string kind,
|
||||
IReadOnlyDictionary<string, string> images,
|
||||
long updatedAt,
|
||||
HashSet<string> written)
|
||||
{
|
||||
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;
|
||||
|
||||
_store.UpsertAsset(itemId, storeLang, kind, url, updatedAt);
|
||||
written.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
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 string FirstNonEmpty(params string?[] values)
|
||||
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
|
||||
}
|
||||
Reference in New Issue
Block a user