using Jellyfin.Data.Enums; using Jellyfin.Plugin.Vanguard.Configuration; using Jellyfin.Plugin.Vanguard.Providers; using Jellyfin.Plugin.Vanguard.Seasons; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Vanguard.Tasks; /// /// Nightly after the broadcast-day rollover: re-bucket every episode (day -> week -> month -> year), /// rename seasons, and refresh series so virtual seasons appear/disappear. Paths never change, so /// item ids and watched state are kept. /// public sealed class SeasonRollTask : IScheduledTask { private readonly ILibraryManager _library; private readonly IFileSystem _fs; private readonly ILogger _log; public SeasonRollTask(ILibraryManager library, IFileSystem fs, ILogger log) { _library = library; _fs = fs; _log = log; } public string Name => "Vanguard: roll seasons"; public string Key => "VanguardSeasonRoll"; public string Description => "Moves recordings between day/week/month/year seasons as time passes and fixes season names."; public string Category => "Vanguard"; public async Task ExecuteAsync(IProgress progress, CancellationToken ct) { var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var tz = VanguardEpisodeProvider.Tz(cfg); var overrides = SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides); var now = DateTimeOffset.UtcNow; var episodes = _library.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Episode], Recursive = true, IsVirtualItem = false }); var touchedSeries = new HashSet(); int moved = 0, i = 0; foreach (var item in episodes.OfType()) { ct.ThrowIfCancellationRequested(); progress.Report(80.0 * ++i / Math.Max(1, episodes.Count)); if (string.IsNullOrEmpty(item.Path)) continue; var when = ItemTimestamp.Resolve(item.Path, tz); var slot = SeasonPolicy.Resolve(when.Utc, now, tz, cfg.BroadcastDayRolloverHour, overrides); if (item.ParentIndexNumber == slot.SeasonNumber && item.IndexNumber == slot.EpisodeNumber) continue; item.ParentIndexNumber = slot.SeasonNumber; item.IndexNumber = slot.EpisodeNumber; await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false); if (item.SeriesId != Guid.Empty) touchedSeries.Add(item.SeriesId); moved++; } // let each touched series re-evaluate its (virtual) seasons, then name every season from its number var options = new MetadataRefreshOptions(new DirectoryService(_fs)) { MetadataRefreshMode = MetadataRefreshMode.Default, ImageRefreshMode = MetadataRefreshMode.None }; foreach (var sid in touchedSeries) { if (_library.GetItemById(sid) is Series s) await s.RefreshMetadata(options, ct).ConfigureAwait(false); } var seasons = _library.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Season], Recursive = true }); int renamed = 0; foreach (var s in seasons.OfType()) { if (!s.IndexNumber.HasValue) continue; var d = SeasonPolicy.Describe(s.IndexNumber.Value, overrides); if (d is null || string.Equals(s.Name, d.Value.Name, StringComparison.Ordinal)) continue; s.Name = d.Value.Name; await s.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false); renamed++; } _log.LogInformation("Season roll: {Moved} episodes re-bucketed, {Renamed} seasons renamed, {Series} series refreshed", moved, renamed, touchedSeries.Count); progress.Report(100); } public IEnumerable GetDefaultTriggers() { var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var hour = Math.Clamp(cfg.BroadcastDayRolloverHour, 0, 23); return [ new TaskTriggerInfo { Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(hour).Add(TimeSpan.FromMinutes(10)).Ticks }, ]; } }