diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/PlaylistAssignment.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/PlaylistAssignment.cs
new file mode 100644
index 0000000..ee26f5d
--- /dev/null
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/PlaylistAssignment.cs
@@ -0,0 +1,16 @@
+namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
+{
+ ///
+ /// The TubeArchivist playlist a video belongs to, resolved to the Jellyfin season
+ /// representing that playlist.
+ ///
+ /// TubeArchivist playlist id.
+ /// Sanitized playlist name, used as the Jellyfin season name.
+ /// Jellyfin season number representing the playlist.
+ /// Position of the video within the playlist.
+ public sealed record PlaylistAssignment(
+ string PlaylistId,
+ string SeasonName,
+ int SeasonNumber,
+ int Index);
+}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/PlaylistCache.cs b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/PlaylistCache.cs
new file mode 100644
index 0000000..54b9a80
--- /dev/null
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/PlaylistCache.cs
@@ -0,0 +1,528 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.TubeArchivistMetadata.Configuration;
+using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+
+namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
+{
+ ///
+ /// Caches the TubeArchivist playlist membership of videos and maps each playlist to a stable
+ /// Jellyfin season number.
+ ///
+ ///
+ ///
+ /// Follows the plugin's static singleton pattern (see );
+ /// the plugin does not use dependency injection for its internals.
+ ///
+ ///
+ /// Caching is required rather than convenient. Retrieving playlists returns every playlist on
+ /// the TubeArchivist server, while episode metadata is requested one video at a time, so an
+ /// uncached lookup would issue that request once per episode during a library scan.
+ ///
+ ///
+ public sealed class PlaylistCache : IDisposable
+ {
+ private static readonly object InstanceLock = new object();
+ private static PlaylistCache? _instance;
+
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _refreshLock = new SemaphoreSlim(1, 1);
+ private readonly object _mapLock = new object();
+
+ // YouTube ids are case sensitive, so lookups must be ordinal.
+ private Dictionary _videoToPlaylist = new Dictionary(StringComparer.Ordinal);
+ private Dictionary _seasonNames = new Dictionary();
+ private DateTime _lastRefreshUtc = DateTime.MinValue;
+ private bool _lastRefreshFailed;
+
+ private PlaylistCache()
+ {
+ if (Plugin.Instance == null)
+ {
+ throw new DataException("Uninitialized plugin!");
+ }
+
+ _logger = Plugin.Instance.Logger;
+ }
+
+ ///
+ /// Gets the cache lifetime before a refresh from TubeArchivist is attempted.
+ ///
+ public static TimeSpan CacheLifetime { get; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Gets the instance of the class.
+ ///
+ /// The PlaylistCache instance.
+ public static PlaylistCache GetInstance()
+ {
+ lock (InstanceLock)
+ {
+ _instance ??= new PlaylistCache();
+ return _instance;
+ }
+ }
+
+ ///
+ /// Resets the cached instance. Intended for tests.
+ ///
+ public static void ResetInstance()
+ {
+ lock (InstanceLock)
+ {
+ _instance?.Dispose();
+ _instance = null;
+ }
+ }
+
+ ///
+ ///
+ /// Present to satisfy CA1001. The cache lives for the lifetime of the process, so
+ /// is the only sanctioned caller: disposing the shared instance
+ /// directly leaves every later lookup throwing .
+ ///
+ public void Dispose()
+ {
+ _refreshLock.Dispose();
+ }
+
+ ///
+ /// Gets the playlist a video belongs to, refreshing the cache when it has expired.
+ ///
+ /// YouTube video id.
+ /// Token used to abort a refresh while a library scan is cancelled.
+ ///
+ /// The playlist assignment, or null when the video belongs to no playlist or the
+ /// playlist data could not be retrieved.
+ ///
+ public async Task GetAssignmentAsync(string videoId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(videoId))
+ {
+ return null;
+ }
+
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+
+ lock (_mapLock)
+ {
+ return _videoToPlaylist.TryGetValue(videoId, out var assignment) ? assignment : null;
+ }
+ }
+
+ ///
+ /// Gets the season name for a season number produced by this cache.
+ ///
+ /// The Jellyfin season number.
+ /// Token used to abort a refresh while a library scan is cancelled.
+ /// The season name, or null when the number is not a known playlist season.
+ public async Task GetSeasonNameAsync(int seasonNumber, CancellationToken cancellationToken = default)
+ {
+ if (seasonNumber == Constants.UnsortedSeasonNumber)
+ {
+ return Constants.UnsortedSeasonName;
+ }
+
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+
+ lock (_mapLock)
+ {
+ return _seasonNames.TryGetValue(seasonNumber, out var name) ? name : null;
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether usable playlist data is available.
+ ///
+ ///
+ /// Lets callers tell "this video is in no playlist" apart from "playlists could not be
+ /// retrieved". Without it a TubeArchivist outage would move an entire library into the
+ /// Unsorted season, and because Jellyfin does not overwrite an existing
+ /// ParentIndexNumber that cannot be undone by a later refresh.
+ ///
+ /// Token used to abort a refresh while a library scan is cancelled.
+ /// true when playlist data was retrieved successfully.
+ public async Task HasPlaylistDataAsync(CancellationToken cancellationToken = default)
+ {
+ await EnsureFreshAsync(cancellationToken).ConfigureAwait(false);
+
+ lock (_mapLock)
+ {
+ return !_lastRefreshFailed && _seasonNames.Count > 0;
+ }
+ }
+
+ private async Task EnsureFreshAsync(CancellationToken cancellationToken)
+ {
+ lock (_mapLock)
+ {
+ if (DateTime.UtcNow - _lastRefreshUtc < CacheLifetime)
+ {
+ return;
+ }
+ }
+
+ await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // Another caller may have refreshed while this one waited.
+ lock (_mapLock)
+ {
+ if (DateTime.UtcNow - _lastRefreshUtc < CacheLifetime)
+ {
+ return;
+ }
+ }
+
+ await RefreshAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _refreshLock.Release();
+ }
+ }
+
+ private async Task RefreshAsync(CancellationToken cancellationToken)
+ {
+ var taApi = TubeArchivistApi.GetInstance();
+ ISet? playlists;
+
+ try
+ {
+ playlists = await taApi.GetPlaylists().ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or JsonException)
+ {
+ // Failure policy: keep whatever is cached and let callers fall back to upload year
+ // grouping. A partial migration is worse than no migration.
+ _logger.LogWarning(ex, "Could not retrieve TubeArchivist playlists. Falling back to upload year seasons.");
+ MarkRefreshFailed();
+ return;
+ }
+
+ if (playlists == null)
+ {
+ _logger.LogWarning("TubeArchivist returned no playlist data. Falling back to upload year seasons.");
+ MarkRefreshFailed();
+ return;
+ }
+
+ var distinctPlaylists = playlists
+ .Where(p => !string.IsNullOrEmpty(p.Id))
+ .GroupBy(p => p.Id, StringComparer.Ordinal)
+ .Select(g => g.First())
+ .ToList();
+
+ // Season names from the persisted map, so seasons keep a usable label for playlists
+ // TubeArchivist no longer returns. Live data below takes precedence.
+ var seasonNames = new Dictionary();
+ var seasonNumbers = await BuildSeasonMapAsync(distinctPlaylists, taApi, seasonNames).ConfigureAwait(false);
+ var videoToPlaylist = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var playlist in distinctPlaylists)
+ {
+ if (!seasonNumbers.TryGetValue(playlist.Id, out var seasonNumber))
+ {
+ continue;
+ }
+
+ var seasonName = Utils.SanitizePlaylistName(playlist.Name, playlist.Id);
+ seasonNames[seasonNumber] = seasonName;
+
+ foreach (var entry in playlist.Entries)
+ {
+ if (string.IsNullOrEmpty(entry.YoutubeId))
+ {
+ continue;
+ }
+
+ // A video can appear in several playlists. Pick the lowest season number so the
+ // choice is deterministic across refreshes.
+ if (videoToPlaylist.TryGetValue(entry.YoutubeId, out var existing))
+ {
+ if (existing.SeasonNumber <= seasonNumber)
+ {
+ continue;
+ }
+
+ _logger.LogDebug(
+ "Video {VideoId} is in multiple playlists. Using season {ChosenSeason} ({ChosenName}) instead of {OtherSeason}.",
+ entry.YoutubeId,
+ seasonNumber,
+ seasonName,
+ existing.SeasonNumber);
+ }
+
+ videoToPlaylist[entry.YoutubeId] = new PlaylistAssignment(playlist.Id, seasonName, seasonNumber, entry.Index);
+ }
+ }
+
+ WarnOnDuplicateSeasonNames(seasonNames);
+
+ lock (_mapLock)
+ {
+ _videoToPlaylist = videoToPlaylist;
+ _seasonNames = seasonNames;
+ _lastRefreshUtc = DateTime.UtcNow;
+ _lastRefreshFailed = false;
+ }
+
+ _logger.LogInformation(
+ "Cached {PlaylistCount} TubeArchivist playlists covering {VideoCount} videos.",
+ seasonNames.Count,
+ videoToPlaylist.Count);
+ }
+
+ ///
+ /// Assigns a Jellyfin season number to every playlist, persisting the result so numbers stay
+ /// stable across restarts.
+ ///
+ ///
+ /// Playlists already present in the configuration keep their number. New playlists are
+ /// appended after the highest number in use, seeded in chronological order by the publish
+ /// date of their first video, so an initial build reads chronologically without later
+ /// additions renumbering existing seasons.
+ ///
+ private async Task> BuildSeasonMapAsync(
+ IReadOnlyCollection playlists,
+ TubeArchivistApi taApi,
+ Dictionary persistedNames)
+ {
+ // Resolve the configuration fresh: saving the plugin settings replaces the whole
+ // PluginConfiguration instance, so a cached reference would be silently orphaned.
+ var configuration = Plugin.Instance?.Configuration;
+ if (configuration == null)
+ {
+ return new Dictionary(StringComparer.Ordinal);
+ }
+
+ var assigned = new Dictionary(StringComparer.Ordinal);
+ var unmapped = new List();
+ var usedSeasons = new HashSet();
+
+ lock (_mapLock)
+ {
+ foreach (var entry in configuration.PlaylistSeasonMap)
+ {
+ if (string.IsNullOrEmpty(entry.PlaylistId))
+ {
+ continue;
+ }
+
+ // The map is user-editable XML. An out-of-range number would either collide with
+ // the Specials season (0), be dropped by Jellyfin (negative), or merge a real
+ // playlist into "Unsorted" (>= 9000), so reject it and let the playlist be
+ // reallocated below.
+ if (entry.SeasonNumber <= 0 || entry.SeasonNumber >= Constants.UnsortedSeasonNumber)
+ {
+ _logger.LogWarning(
+ "Ignoring out of range season number {SeasonNumber} for playlist {PlaylistId}.",
+ entry.SeasonNumber,
+ entry.PlaylistId);
+ continue;
+ }
+
+ // Two playlists sharing a season would merge into one Jellyfin season, with the
+ // name decided by whichever was read last. Keep the first and reallocate the
+ // other below, same as an out-of-range entry.
+ if (!usedSeasons.Add(entry.SeasonNumber))
+ {
+ _logger.LogWarning(
+ "Ignoring duplicate season number {SeasonNumber} for playlist {PlaylistId}.",
+ entry.SeasonNumber,
+ entry.PlaylistId);
+ continue;
+ }
+
+ assigned[entry.PlaylistId] = entry.SeasonNumber;
+
+ // Keeps season names available when TubeArchivist cannot be reached.
+ if (!string.IsNullOrEmpty(entry.PlaylistName))
+ {
+ persistedNames[entry.SeasonNumber] = entry.PlaylistName;
+ }
+ }
+ }
+
+ foreach (var playlist in playlists)
+ {
+ if (!assigned.ContainsKey(playlist.Id))
+ {
+ unmapped.Add(playlist);
+ }
+ }
+
+ if (unmapped.Count == 0)
+ {
+ return assigned;
+ }
+
+ var seeds = new List<(Playlist Playlist, DateTime Sort)>();
+ foreach (var playlist in unmapped)
+ {
+ seeds.Add((playlist, await GetPlaylistSortDateAsync(playlist, taApi).ConfigureAwait(false)));
+ }
+
+ var ordered = seeds
+ .OrderBy(s => s.Sort)
+ .ThenBy(s => s.Playlist.Name, StringComparer.Ordinal)
+ .Select(s => s.Playlist)
+ .ToList();
+
+ var nextSeason = assigned.Count == 0 ? 1 : assigned.Values.Max() + 1;
+ var added = new List();
+
+ for (var i = 0; i < ordered.Count; i++)
+ {
+ var playlist = ordered[i];
+ if (nextSeason >= Constants.UnsortedSeasonNumber)
+ {
+ var remaining = ordered.Count - i;
+ _logger.LogError(
+ "Reached the maximum of {Maximum} playlist seasons. {Remaining} playlist(s) will be grouped into the {SeasonName} season, starting with {PlaylistName} ({PlaylistId}).",
+ Constants.UnsortedSeasonNumber - 1,
+ remaining,
+ Constants.UnsortedSeasonName,
+ playlist.Name,
+ playlist.Id);
+ break;
+ }
+
+ assigned[playlist.Id] = nextSeason;
+ added.Add(new PlaylistSeasonMapEntry
+ {
+ PlaylistId = playlist.Id,
+ SeasonNumber = nextSeason,
+ PlaylistName = Utils.SanitizePlaylistName(playlist.Name, playlist.Id)
+ });
+
+ nextSeason++;
+ }
+
+ if (added.Count > 0)
+ {
+ PersistNewEntries(added);
+ }
+
+ return assigned;
+ }
+
+ ///
+ /// Gets the date used to order a playlist against the others.
+ ///
+ ///
+ /// TubeArchivist exposes no stable playlist date: playlist_last_refresh changes on
+ /// every sync. The publish date of the playlist's first video is used instead, which is an
+ /// approximation because playlist order is not strictly chronological.
+ ///
+ private async Task GetPlaylistSortDateAsync(Playlist playlist, TubeArchivistApi taApi)
+ {
+ var firstEntry = playlist.Entries
+ .Where(e => !string.IsNullOrEmpty(e.YoutubeId))
+ .OrderBy(e => e.Index)
+ .FirstOrDefault();
+
+ if (firstEntry == null)
+ {
+ return DateTime.MaxValue;
+ }
+
+ try
+ {
+ var video = await taApi.GetVideo(firstEntry.YoutubeId).ConfigureAwait(false);
+ if (video != null)
+ {
+ return video.Published;
+ }
+ }
+ catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or JsonException)
+ {
+ _logger.LogDebug(ex, "Could not resolve a sort date for playlist {PlaylistId}.", playlist.Id);
+ }
+
+ // Undated playlists sort last so they never displace dated ones.
+ return DateTime.MaxValue;
+ }
+
+ ///
+ /// Adds newly allocated playlist seasons to the persisted map.
+ ///
+ ///
+ ///
+ /// is called outside the lock: it writes to disk, and holding a monitor across it would
+ /// block every other scan thread. That leaves a millisecond window in which saving the
+ /// settings page could replace the configuration instance and drop these entries; they are
+ /// simply reallocated on the next refresh. The window cannot be closed from here because
+ /// Jellyfin's own config swap does not take this lock.
+ ///
+ /// The entries to persist.
+ private void PersistNewEntries(List added)
+ {
+ var plugin = Plugin.Instance;
+ if (plugin == null)
+ {
+ return;
+ }
+
+ lock (_mapLock)
+ {
+ var map = plugin.Configuration.PlaylistSeasonMap;
+
+ foreach (var entry in added)
+ {
+ // An entry for this playlist may already exist holding a value the map builder
+ // rejected as out of range. Skipping it would leave the bad value on disk to be
+ // re-read and re-rejected on every refresh, so the map would never heal.
+ for (var i = map.Count - 1; i >= 0; i--)
+ {
+ if (string.Equals(map[i].PlaylistId, entry.PlaylistId, StringComparison.Ordinal))
+ {
+ map.RemoveAt(i);
+ }
+ }
+
+ map.Add(entry);
+ }
+ }
+
+ // Mutating the collection alone does not reach disk.
+ plugin.SaveConfiguration();
+
+ _logger.LogInformation("Assigned season numbers to {Count} new TubeArchivist playlists.", added.Count);
+ }
+
+ private void WarnOnDuplicateSeasonNames(Dictionary seasonNames)
+ {
+ var duplicates = seasonNames
+ .GroupBy(kvp => kvp.Value, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1);
+
+ foreach (var duplicate in duplicates)
+ {
+ _logger.LogWarning(
+ "Playlists share the season name {SeasonName}. They remain separate seasons (numbers {SeasonNumbers}).",
+ duplicate.Key,
+ string.Join(", ", duplicate.Select(d => d.Key.ToString(CultureInfo.InvariantCulture))));
+ }
+ }
+
+ private void MarkRefreshFailed()
+ {
+ lock (_mapLock)
+ {
+ _lastRefreshFailed = true;
+
+ // Retry on the next lookup rather than serving stale data for the full lifetime.
+ _lastRefreshUtc = DateTime.UtcNow - CacheLifetime + TimeSpan.FromMinutes(1);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs b/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs
index f526db0..b37d3fd 100644
--- a/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs
+++ b/Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs
@@ -13,6 +13,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
private const string YTTAPlaylistNameFormatRegex = @"^(.*)\s\-\s(.*)\s\((.*)\)$";
private const string TAPlaylistNameFormatRegex = @"^(.*)\s\((.*)\)$";
+ ///
+ /// Maximum length of a season name derived from a TubeArchivist playlist name.
+ ///
+ private const int MaxSeasonNameLength = 120;
+
///
/// Sanitizes the given URL.
///
@@ -169,5 +174,50 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
return name;
}
+
+ ///
+ /// Sanitizes a TubeArchivist playlist name for use as a Jellyfin season name.
+ ///
+ ///
+ /// Season names are stored in the Jellyfin database rather than used as file paths, so this
+ /// only normalises whitespace, strips control characters and caps the length. Punctuation is
+ /// preserved so seasons read the same as they do in TubeArchivist.
+ ///
+ /// The TubeArchivist playlist name.
+ /// Value to use when the playlist name is missing or blank, normally the playlist id.
+ /// A season name safe to persist, never null or empty.
+ public static string SanitizePlaylistName(string? playlistName, string? fallback)
+ {
+ var sanitized = playlistName ?? string.Empty;
+
+ // Strip control characters (e.g. stray newlines) before collapsing whitespace.
+ sanitized = new string(sanitized.Where(c => !char.IsControl(c)).ToArray());
+ sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
+
+ if (string.IsNullOrEmpty(sanitized))
+ {
+ sanitized = (fallback ?? string.Empty).Trim();
+ }
+
+ if (string.IsNullOrEmpty(sanitized))
+ {
+ return Constants.UnsortedSeasonName;
+ }
+
+ if (sanitized.Length > MaxSeasonNameLength)
+ {
+ // Cutting between a surrogate pair would leave a lone surrogate, which XmlSerializer
+ // refuses to write, breaking the whole plugin configuration save.
+ var cut = MaxSeasonNameLength;
+ if (char.IsHighSurrogate(sanitized[cut - 1]))
+ {
+ cut--;
+ }
+
+ sanitized = sanitized.Substring(0, cut).TrimEnd();
+ }
+
+ return sanitized;
+ }
}
}