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 _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 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 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 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 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(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(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 languages, string kind, IReadOnlyDictionary images, PluginConfiguration cfg, long updatedAt, HashSet 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 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 BuildItemInfos(IEnumerable 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(); 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 GetItemAndChildren(BaseItem root, bool includeChildren) { var items = new List { 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(); 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 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; }