feat: add a task to rebuild playlist seasons

Toggling "Group seasons by TubeArchivist playlist" had no effect on a
library whose episodes were already imported. An episode's
ParentIndexNumber is sticky: MetadataService copies the stored value
onto the working item before any provider runs, so it is never empty and
providers are never asked for a replacement. Even a full "Replace all
metadata" refresh leaves episodes where they are - measured, 0 of 116
moved.

The task clears ParentIndexNumber and then refreshes each episode, which
gives the provider nothing to preserve and lets the current setting
decide the season. Verified live in both directions on 116 episodes:
playlist seasons to upload years and back to the original grouping, with
watch state, resume positions and media files untouched. Jellyfin
removes the emptied seasons itself and season names recover on the next
library scan.

Deleting the seasons instead does not work, which is worth recording
because it is the obvious approach: Jellyfin derives a season's id from
its series and index number, and deleting one does not clear its
episodes, so a rescan recreates exactly the same seasons. Measured: 30
deleted, 0 episodes moved.

The task has no default trigger. Regrouping a library is disruptive and
must be an explicit choice, not a side effect of saving the settings
page. It also deletes nothing, which keeps it clear of the media loss
risk that any DELETE against Jellyfin carries.

Scoping uses the library's physical folders rather than
InternalItemsQuery.AncestorIds. A CollectionFolder lives under
/config/root and is not in an episode's ancestor chain, so filtering by
its id matches nothing and Jellyfin treats the empty filter as no filter
at all - the first live run processed 215 episodes across the whole
server instead of the library's 116, including items orphaned by a
removed library.

Clearing the season calls ILibraryManager.UpdateItemAsync directly
rather than BaseItem.UpdateToRepositoryAsync, which resolves its parent
through the static BaseItem.LibraryManager that a plugin cannot rely on.

Note: AI Generated Commit
This commit is contained in:
7hr08ik 2026-08-09 12:52:50 +01:00
parent bc2693cd9b
commit bd35ae94b1
3 changed files with 264 additions and 12 deletions

View File

@ -64,10 +64,12 @@
<div class="fieldDescription">Groups episodes into seasons named after their TubeArchivist
playlist instead of their upload year. Videos which do not belong to any playlist are
grouped into a season named "Unsorted".</div>
<div class="fieldDescription" style="color: #cc5b5b;">After changing this setting, run Refresh
metadata with "Replace all metadata" enabled. Jellyfin keeps an episode's existing season
once it is set, so a normal library scan will not move episodes that have already been
imported.</div>
<div class="fieldDescription" style="color: #cc5b5b;">After changing this setting, run the
"Rebuild playlist seasons" task from Dashboard &rarr; Scheduled Tasks. Jellyfin keeps an
episode's existing season once it is set, so neither a library scan nor a "Replace all
metadata" refresh will move episodes which have already been imported. The task deletes
nothing: it clears each episode's stored season number so the setting above can be applied
again.</div>
</div>
<div>
<h2>Synchronization</h2>

View File

@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
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.TubeArchivistMetadata.Tasks
{
/// <summary>
/// Task which re-evaluates the season every episode belongs to.
/// </summary>
/// <remarks>
/// <para>
/// An episode's <c>ParentIndexNumber</c> is sticky: <c>MetadataService</c> copies the stored
/// value onto the working item before any provider runs, so the value is never empty and
/// providers are never asked to supply a new one. Even a "Replace all metadata" refresh
/// therefore leaves existing episodes in whatever season they were first imported into, which
/// means toggling <c>SortSeasonsByPlaylist</c> has no effect on an existing library.
/// </para>
/// <para>
/// Clearing the value first is what breaks the cycle. Once it is null the refresh has nothing to
/// preserve and the episode provider assigns a season according to the current configuration.
/// Deleting the seasons instead does not work: Jellyfin derives a season's id from its series
/// and index number, and deleting one does not clear its episodes, so a rescan recreates
/// exactly the same seasons.
/// </para>
/// <para>
/// Nothing is deleted. Only <c>ParentIndexNumber</c> is modified, so media files, watch state
/// and resume positions are unaffected, and Jellyfin removes the emptied seasons itself.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public class RebuildPlaylistSeasonsTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IProviderManager _providerManager;
private readonly IFileSystem _fileSystem;
/// <summary>
/// Initializes a new instance of the <see cref="RebuildPlaylistSeasonsTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="providerManager">Provider manager.</param>
/// <param name="fileSystem">File system.</param>
public RebuildPlaylistSeasonsTask(
ILogger<Plugin> logger,
ILibraryManager libraryManager,
IProviderManager providerManager,
IFileSystem fileSystem)
{
_logger = logger;
_libraryManager = libraryManager;
_providerManager = providerManager;
_fileSystem = fileSystem;
}
/// <inheritdoc/>
public string Name => "Rebuild playlist seasons";
/// <inheritdoc/>
public string Description => "Re-evaluates which season each episode belongs to, applying the current \"Group seasons by TubeArchivist playlist\" setting to episodes which were already imported. Run this after changing that setting. Nothing is deleted.";
/// <inheritdoc/>
public string Category => "TubeArchivistMetadata";
/// <inheritdoc/>
public string Key => "RebuildPlaylistSeasonsTask";
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(0);
var collectionTitle = Plugin.Instance?.Configuration.CollectionTitle;
if (string.IsNullOrEmpty(collectionTitle))
{
_logger.LogWarning("No collection title is configured, so there is no library to rebuild.");
progress.Report(100);
return;
}
var start = DateTime.Now;
var episodes = GetEpisodes(collectionTitle);
if (episodes.Count == 0)
{
_logger.LogWarning(
"Found no episodes in the collection {CollectionTitle}. Check that the collection title matches the library name.",
collectionTitle);
progress.Report(100);
return;
}
_logger.LogInformation(
"Rebuilding seasons for {EpisodeCount} episode(s) in {CollectionTitle}. Grouping by playlist is {State}.",
episodes.Count,
collectionTitle,
Plugin.Instance!.Configuration.SortSeasonsByPlaylist ? "enabled" : "disabled");
var processed = 0;
var cleared = 0;
var failed = 0;
foreach (var episode in episodes)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
// Clearing the stored season is the whole point of the task: the refresh below
// only reassigns a season when there is no existing value to preserve.
if (episode.ParentIndexNumber.HasValue)
{
episode.ParentIndexNumber = null;
// Calling the injected manager rather than BaseItem.UpdateToRepositoryAsync,
// which resolves the parent through the static BaseItem.LibraryManager.
await _libraryManager.UpdateItemAsync(
episode,
episode.GetParent(),
ItemUpdateType.MetadataEdit,
cancellationToken).ConfigureAwait(false);
cleared++;
}
var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ImageRefreshMode = MetadataRefreshMode.None,
ReplaceAllMetadata = true,
ReplaceAllImages = false
};
await _providerManager.RefreshSingleItem(episode, refreshOptions, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// One unreadable episode must not abandon the rest of the library part way
// through, which would leave it split across two grouping schemes.
failed++;
_logger.LogError(ex, "Could not rebuild the season for {EpisodeName}.", episode.Name);
}
processed++;
progress.Report(processed * 100.0 / episodes.Count);
}
_logger.LogInformation(
"Rebuilt {Cleared} episode season(s) with {Failed} failure(s) in {Elapsed}. Season names refresh on the next library scan.",
cleared,
failed,
DateTime.Now - start);
progress.Report(100);
}
/// <inheritdoc/>
/// <remarks>
/// Intentionally empty. This task only ever runs when started by hand.
/// </remarks>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => Array.Empty<TaskTriggerInfo>();
/// <summary>
/// Finds every episode stored under the configured collection's folders.
/// </summary>
/// <remarks>
/// Scoping deliberately uses the library's physical paths rather than
/// <c>InternalItemsQuery.AncestorIds</c>. A <c>CollectionFolder</c> lives under
/// <c>/config/root</c> and is not part of an episode's ancestor chain, which runs
/// Series to media Folder to AggregateFolder. Filtering by the collection's id therefore
/// matches nothing, and Jellyfin treats an empty ancestor filter as "no filter" and returns
/// every episode on the server - including items left behind by removed libraries.
/// </remarks>
/// <param name="collectionTitle">The configured collection title.</param>
/// <returns>The episodes to rebuild.</returns>
private IReadOnlyList<Episode> GetEpisodes(string collectionTitle)
{
var locations = _libraryManager.GetVirtualFolders()
.Where(f => string.Equals(f.Name, collectionTitle, StringComparison.OrdinalIgnoreCase))
.SelectMany(f => f.Locations ?? Array.Empty<string>())
.Where(l => !string.IsNullOrEmpty(l))
.ToArray();
if (locations.Length == 0)
{
_logger.LogWarning(
"Library {CollectionTitle} was not found, or has no folders configured.",
collectionTitle);
return Array.Empty<Episode>();
}
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Episode },
Recursive = true
});
var episodes = items
.OfType<Episode>()
.Where(e => !string.IsNullOrEmpty(e.Path)
&& locations.Any(l => e.Path.StartsWith(
l.EndsWith(Path.DirectorySeparatorChar) ? l : l + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)))
.ToList();
_logger.LogDebug(
"Matched {EpisodeCount} episode(s) under {LocationCount} folder(s) of {CollectionTitle}.",
episodes.Count,
locations.Length,
collectionTitle);
return episodes;
}
}
}

View File

@ -108,9 +108,6 @@ seasons kept their playlist names. This holds even though `MergeData` does gate
item with the *existing* `ParentIndexNumber` before any provider runs, so the value is never empty
and the plugin is not asked to supply a new one.
**To fully revert**, disable the setting and then remove the affected seasons so Jellyfin recreates
them on the next scan.
### The same limit applies when first enabling the feature
This is the important consequence for existing installations. Because `ParentIndexNumber` is sticky
@ -121,12 +118,33 @@ New episodes imported after enabling the setting are grouped by playlist correct
scanned for the first time with the setting already on is grouped entirely by playlist — verified on
a brand-new library.
To convert an existing year-grouped library, delete its **Season** entities (never the Series) and
rescan.
### 3. Converting an existing library: run the "Rebuild playlist seasons" task
> ⚠️ **Do not delete the Series to reset it.** In Jellyfin, `DELETE /Items/{id}` on a Series, Season
> or Episode **deletes the media files from disk**. There is no trash or recycle bin. Delete only the
> Season entities, or point a throwaway library at a copy of the media.
**Dashboard → Scheduled Tasks → Rebuild playlist seasons → run manually.**
The task clears each episode's stored season number in libraries matching the configured collection
title, then refreshes them so the current setting decides the season afresh. It works in both
directions: enabling the setting regroups a year-based library by playlist, and disabling it returns
episodes to upload-year seasons.
It has no automatic trigger. Regrouping a library is disruptive enough that it should happen when you
choose, not as a side effect of saving a settings page.
**It deletes nothing.** Only the season number field on each episode is modified. Verified on a
116-episode library: media files untouched, watch state and resume positions preserved, no orphaned
seasons left behind. Jellyfin removes the now-empty old seasons by itself.
Season *names* may briefly read "Season Unknown" immediately afterwards. That is a cached field on
the episode and the next library refresh restores the correct name.
> **Deleting the seasons by hand does not work** — worth stating, because it is the obvious approach.
> Jellyfin derives a season's item id deterministically from its series and index number, and
> deleting a season does not clear its episodes' stored season number, so a rescan recreates exactly
> the same seasons. Measured: 30 seasons deleted, 0 episodes moved.
> ⚠️ **Never delete a Series or Episode to reset state.** In Jellyfin, `DELETE /Items/{id}` on a
> file-backed item **deletes the media from disk**, and there is no trash or recycle bin. The task
> above avoids deletion entirely for exactly this reason.
---