diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/RebuildPlaylistSeasonsTask.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/RebuildPlaylistSeasonsTask.cs index 64d2764..58ed865 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/RebuildPlaylistSeasonsTask.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Tasks/RebuildPlaylistSeasonsTask.cs @@ -38,6 +38,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks /// and resume positions are unaffected, and Jellyfin removes the emptied seasons itself. /// /// + /// Reassigning the episodes is only half the work. Seasons are separate items, and refreshing + /// an episode neither creates the season it now belongs to nor re-parents it, so between the + /// two phases the library shows the previous seasons standing empty. The affected series are + /// therefore refreshed once the episodes are done, which is what drives Jellyfin's season + /// creation and removes the seasons left behind. + /// + /// /// There is no default trigger. Regrouping a library is disruptive and must be an explicit /// choice rather than a side effect of saving the settings page. /// @@ -55,6 +62,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks /// public class RebuildPlaylistSeasonsTask : IScheduledTask { + /// + /// Share of the reported progress given to the episode pass, leaving the rest for the + /// series pass so the task does not appear finished while work remains. + /// + private const double EpisodePhaseShare = 90.0; + private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; private readonly IProviderManager _providerManager; @@ -179,18 +192,101 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks } processed++; - progress.Report(processed * 100.0 / episodes.Count); + + // The episode pass owns the first 90%; the series pass reports the rest. + progress.Report(processed * EpisodePhaseShare / episodes.Count); } + var seriesFailed = await RebuildSeasonsAsync(episodes, progress, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( - "Rebuilt {Cleared} episode season(s) with {Failed} failure(s) in {Elapsed}. Season names refresh on the next library scan.", + "Rebuilt {Cleared} episode season(s) with {Failed} failure(s) in {Elapsed}.", cleared, - failed, + failed + seriesFailed, DateTime.Now - start); progress.Report(100); } + /// + /// Refreshes the series the rebuilt episodes belong to, so their seasons are recreated. + /// + /// + /// Only the affected series are refreshed rather than the whole library: the episodes are + /// already in hand, so the distinct parents are known, and this leaves other libraries + /// untouched. A default refresh is enough - the season structure is rebuilt from the + /// episodes' current ParentIndexNumber, so there is no need to re-fetch metadata + /// that the episode pass has just written. + /// + /// The episodes that were rebuilt. + /// Progress reporter, covering the final tenth of the run. + /// The cancellation token. + /// The number of series that could not be refreshed. + private async Task RebuildSeasonsAsync( + IReadOnlyList episodes, + IProgress progress, + CancellationToken cancellationToken) + { + // Resolved through the injected manager rather than Episode.Series, which reads the + // static BaseItem.LibraryManager. + var series = episodes + .Select(episode => episode.SeriesId) + .Where(id => !id.Equals(Guid.Empty)) + .Distinct() + .Select(id => _libraryManager.GetItemById(id) as Series) + .Where(parent => parent != null) + .ToList(); + + if (series.Count == 0) + { + _logger.LogWarning( + "Could not resolve the series for any rebuilt episode, so their seasons were left as they were. A library scan will correct them."); + return 0; + } + + _logger.LogInformation("Rebuilding the seasons of {SeriesCount} series.", series.Count); + + var refreshed = 0; + var failed = 0; + + foreach (var parent in series) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.Default, + ImageRefreshMode = MetadataRefreshMode.None, + ReplaceAllMetadata = false, + ReplaceAllImages = false + }; + + await _providerManager.RefreshSingleItem(parent!, refreshOptions, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Logged loudly: a series left unrefreshed keeps its previous seasons, which + // appear empty because their episodes have already moved elsewhere. + failed++; + _logger.LogError( + ex, + "Could not rebuild the seasons of {SeriesName}. Its seasons may appear empty until the next library scan.", + parent!.Name); + } + + refreshed++; + progress.Report(EpisodePhaseShare + (refreshed * (100.0 - EpisodePhaseShare) / series.Count)); + } + + return failed; + } + /// /// /// Intentionally empty. This task only ever runs when started by hand.