Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Jellyfin.Server.Migrations.Routines;

/// <summary>
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
/// </summary>
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;

/// <summary>
/// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="dbProvider">The database context factory.</param>
public RecomputeSeriesPresentationKey(
IStartupLogger<RecomputeSeriesPresentationKey> logger,
ILibraryManager libraryManager,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_logger = logger;
_libraryManager = libraryManager;
_dbProvider = dbProvider;
}

/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Series]
}).OfType<Series>().ToArray();

_logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length);

const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
var processed = 0;
var updated = 0;

var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
foreach (var item in series)
{
cancellationToken.ThrowIfCancellationRequested();

if (++processed % ProgressInterval == 0)
{
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}

var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
{
continue;
}

// Write only the changed column instead of re-persisting the whole item.
var id = item.Id;
await dbContext.BaseItems
.Where(e => e.Id.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);

// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
// to the series by it. Re-point every child still carrying the old key in a single set-based
// update so they stay attached without waiting for the next scan.
if (!string.IsNullOrEmpty(oldKey))
{
await dbContext.BaseItems
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
}

updated++;
}
}

_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
}
}
50 changes: 37 additions & 13 deletions MediaBrowser.Controller/Entities/TV/Series.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
Expand Down Expand Up @@ -82,33 +81,39 @@ public override string CreatePresentationUniqueKey()
{
var userdatakeys = GetUserDataKeys();

if (userdatakeys.Count > 1)
// The first user data key is a stable cross-folder identity.
// When none exists, fall back to the (normalized) series name.
var groupingKey = userdatakeys.Count > 1
? userdatakeys[0]
: GetNameBasedGroupingKey();

if (!string.IsNullOrEmpty(groupingKey))
{
return AddLibrariesToPresentationUniqueKey(userdatakeys[0]);
return AppendPreferredLanguage(groupingKey);
}
}

return base.CreatePresentationUniqueKey();
}

private string AddLibrariesToPresentationUniqueKey(string key)
// The owning libraries are deliberately NOT part of the key.
private string AppendPreferredLanguage(string key)
{
var lang = GetPreferredMetadataLanguage();
if (!string.IsNullOrEmpty(lang))
{
key += "-" + lang;
}

var folders = LibraryManager.GetCollectionFolders(this)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray();

if (folders.Length == 0)
{
return key;
}
return key;
}

return key + "-" + string.Join('-', folders);
private string GetNameBasedGroupingKey()
{
// Prefix with the type so a series can never collide with a same-named item of another kind.
return string.IsNullOrEmpty(Name)
? null
: "series-" + Name.ToLowerInvariant();
}

private static string GetUniqueSeriesKey(BaseItem series)
Expand Down Expand Up @@ -188,6 +193,25 @@ public override List<string> GetUserDataKeys()
return list;
}

/// <inheritdoc />
protected override Guid[] GetExtraOwnerIds()
{
if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping)
{
return base.GetExtraOwnerIds();
}

// Setting PresentationUniqueKey on the query disables presentation-key grouping, so this
// returns every folder-item of the merged series rather than the collapsed survivor.
var ids = LibraryManager.GetItemIds(new InternalItemsQuery
{
PresentationUniqueKey = GetPresentationUniqueKey(),
IncludeItemTypes = [BaseItemKind.Series]
});

return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray();
}

public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
{
return GetSeasons(user, new DtoOptions(true));
Expand Down
Loading