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.
This commit is contained in:
7hr08ik 2026-08-09 01:27:44 +01:00
parent 838176a83d
commit 1e7bdfb99d
2 changed files with 50 additions and 3 deletions

View File

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

View File

@ -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
/// <returns>The video equivalent Jellyfin <see cref="Episode"/> object.</returns>
public Episode ToEpisode()
{
return ToEpisode(null);
}
/// <summary>
/// Converts the TubeArchivist API video to a Jellyfin <see cref="Episode"/> object,
/// optionally grouping it into the season representing its TubeArchivist playlist.
/// </summary>
/// <param name="playlistAssignment">
/// The playlist the video belongs to, or <c>null</c> to group the video by upload year.
/// </param>
/// <returns>The video equivalent Jellyfin <see cref="Episode"/> object.</returns>
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,