From 1e7bdfb99de38fc1a7eb0c1d0573bf9243ff0a79 Mon Sep 17 00:00:00 2001 From: 7hr08ik <7hr08ik@gmail.com> Date: Sun, 9 Aug 2026 01:27:44 +0100 Subject: [PATCH] feat: group episodes into playlist seasons Wires PlaylistCache into episode metadata. With SortSeasonsByPlaylist enabled, an episode's ParentIndexNumber comes from its playlist's season number instead of its upload year. ParentIndexNumber is the only real grouping key available: the media is a flat folder, so every season is virtual and Jellyfin overwrites any provider-set SeasonName. ToEpisode gains an overload taking a nullable PlaylistAssignment; the existing signature delegates to it so upstream callers are untouched. The decision to bucket a video into Unsorted lives in the provider, not in ToEpisode, because only the provider can distinguish "this video is in no playlist" from "playlist data is unavailable". Conflating them meant a partial TubeArchivist outage, where /api/video responds but /api/playlist does not, wrote season 9000 to every episode. That value is sticky: MergeData only overwrites empty targets, so a later refresh cannot repair it. The provider now consults HasPlaylistDataAsync and falls back to the upload year when playlist data genuinely is missing. NumberingScheme.PlaylistIndex maps an episode's IndexNumber to its position within the playlist, and is left unset when no assignment exists. --- .../Providers/EpisodeMetadataProvider.cs | 25 ++++++++++++++++- .../TubeArchivist/Video/Video.cs | 28 +++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Providers/EpisodeMetadataProvider.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Providers/EpisodeMetadataProvider.cs index d62e530..c38e96c 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/Providers/EpisodeMetadataProvider.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/Providers/EpisodeMetadataProvider.cs @@ -65,8 +65,31 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers ImageUrl = video.Channel.ThumbUrl, Type = Data.Enums.PersonKind.Actor, }); + PlaylistAssignment? playlistAssignment = null; + if (Plugin.Instance?.Configuration.SortSeasonsByPlaylist == true) + { + var playlistCache = PlaylistCache.GetInstance(); + playlistAssignment = await playlistCache.GetAssignmentAsync(videoTAId, cancellationToken).ConfigureAwait(true); + + if (playlistAssignment == null) + { + // Only bucket into "Unsorted" when the playlists were actually retrieved. + // If TubeArchivist is unreachable the video falls back to its upload year, + // which Jellyfin can still correct on a later refresh. + if (await playlistCache.HasPlaylistDataAsync(cancellationToken).ConfigureAwait(true)) + { + _logger.LogDebug("{Message}", string.Format(CultureInfo.CurrentCulture, "No TubeArchivist playlist found for video {0}. Grouping it into the {1} season.", videoTAId, Constants.UnsortedSeasonName)); + playlistAssignment = new PlaylistAssignment(string.Empty, Constants.UnsortedSeasonName, Constants.UnsortedSeasonNumber, 0); + } + else + { + _logger.LogWarning("{Message}", string.Format(CultureInfo.CurrentCulture, "TubeArchivist playlists unavailable. Grouping video {0} by upload year.", videoTAId)); + } + } + } + result.HasMetadata = true; - result.Item = video.ToEpisode(); + result.Item = video.ToEpisode(playlistAssignment); result.Item.Path = info.Path; result.Provider = Name; result.People = peopleInfo; diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Video/Video.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Video/Video.cs index 22b3f44..9c810c6 100644 --- a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Video/Video.cs +++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/Video/Video.cs @@ -9,6 +9,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist @@ -121,15 +122,38 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist /// The video equivalent Jellyfin object. public Episode ToEpisode() { + return ToEpisode(null); + } + + /// + /// Converts the TubeArchivist API video to a Jellyfin object, + /// optionally grouping it into the season representing its TubeArchivist playlist. + /// + /// + /// The playlist the video belongs to, or null to group the video by upload year. + /// + /// The video equivalent Jellyfin object. + public Episode ToEpisode(PlaylistAssignment? playlistAssignment) + { + // A null assignment always means "group by upload year". Deciding when an unassigned + // video belongs in the Unsorted season needs to know whether playlist data was actually + // retrieved, so the caller makes that call and passes an Unsorted assignment instead. + var seasonNumber = playlistAssignment?.SeasonNumber ?? Published.Year; + var seasonName = playlistAssignment?.SeasonName ?? Published.Year.ToString(CultureInfo.CurrentCulture); + return new Episode { Name = Title, Overview = Utils.FormatDescription(Description), - SeasonName = Published.Year.ToString(CultureInfo.CurrentCulture), - ParentIndexNumber = Published.Year, + + // Jellyfin overwrites SeasonName from the parent Season entity; it is set here only + // for consistency. Season naming is handled by SeasonMetadataProvider. + SeasonName = seasonName, + ParentIndexNumber = seasonNumber, IndexNumber = Plugin.Instance?.Configuration?.EpisodeNumberingScheme switch { NumberingScheme.YYYYMMDD => (Published.Year * 10000) + (Published.Month * 100) + Published.Day, + NumberingScheme.PlaylistIndex => playlistAssignment?.Index, _ => null }, SeriesName = Channel.Name,