Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/Refresh/RefreshService.cs
T

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