aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs (renamed from Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs)79
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs334
2 files changed, 398 insertions, 15 deletions
diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
index 60bb3fd1db..0e50ec2f47 100644
--- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -15,9 +17,9 @@ 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.
+/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
/// </summary>
-[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
+[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
@@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
+ var newSeriesKeys = new Dictionary<Guid, string>();
var processed = 0;
var updated = 0;
@@ -68,9 +71,10 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
_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))
+ newSeriesKeys[item.Id] = newKey;
+
+ if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
@@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
.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);
- }
+ // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
+ // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
+ // by the old key: that key can be shared by every library holding the series, so matching
+ // on it would drag the other libraries' children along.
+ await dbContext.BaseItems
+ .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
+ .ConfigureAwait(false);
updated++;
}
+
+ var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}",
+ updated,
+ series.Length,
+ updatedSeasons,
+ sw.Elapsed);
+ }
+ }
+
+ private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken)
+ {
+ // A season's own key embeds its series' key, so it goes stale with it.
+ var seasons = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Season]
+ }).OfType<Season>().ToArray();
+
+ var updated = 0;
+
+ foreach (var season in seasons)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Without an index number the season keeps the base key, which carries no series key at all.
+ if (!season.IndexNumber.HasValue
+ || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
+ {
+ continue;
+ }
+
+ // Mirrors Season.CreatePresentationUniqueKey.
+ var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
+ if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var id = season.Id;
+ await dbContext.BaseItems
+ .Where(e => e.Id.Equals(id))
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
+ .ConfigureAwait(false);
+
+ updated++;
}
- _logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
+ return updated;
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
new file mode 100644
index 0000000000..3fc2387e09
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
@@ -0,0 +1,334 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Moves the views whose id used to be derived from their localized name onto their name independent id.
+/// </summary>
+[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _configurationManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class.
+ /// </summary>
+ /// <param name="logger">The startup logger.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="configurationManager">The server configuration manager.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ public ConsolidateLocalizedUserViews(
+ IStartupLogger<ConsolidateLocalizedUserViews> logger,
+ ILibraryManager libraryManager,
+ IServerConfigurationManager configurationManager,
+ IFileSystem fileSystem,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _configurationManager = configurationManager;
+ _fileSystem = fileSystem;
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ // The Live TV view is the one that hurts: every channel and program is parented to it, so a
+ // translation update or a change of UI culture used to leave them behind under a view nothing
+ // looks up any more.
+ var views = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.UserView]
+ }).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray();
+
+ if (views.Length == 0)
+ {
+ return;
+ }
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ foreach (var group in views.GroupBy(view => view.ViewType!.Value))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var viewType = group.Key;
+ var folderName = _fileSystem.GetValidFilename(viewType.ToString());
+ var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName);
+
+ // Only the views created for a view type as a whole are named after it. The per user and
+ // per parent ones get a folder of their own, and carry no children to lose. Match on the
+ // folder rather than the whole path so a metadata directory that has since moved still
+ // lines up.
+ var candidates = group
+ .Where(view => !string.IsNullOrEmpty(view.Path)
+ && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (candidates.Length == 0)
+ {
+ continue;
+ }
+
+ // Mirrors LibraryManager.GetNamedView(name, viewType, sortName).
+ var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
+
+ var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray();
+ if (stale.Length == 0)
+ {
+ continue;
+ }
+
+ await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task ConsolidateAsync(
+ JellyfinDbContext dbContext,
+ CollectionType viewType,
+ string path,
+ Guid canonicalId,
+ IReadOnlyList<UserView> candidates,
+ IReadOnlyList<UserView> stale,
+ CancellationToken cancellationToken)
+ {
+ var staleIds = stale.Select(view => view.Id).ToArray();
+ Guid? newParentId = canonicalId;
+ var sourceId = Guid.Empty;
+
+ if (!candidates.Any(view => view.Id.Equals(canonicalId)))
+ {
+ // Whichever of the old views the items ended up under is the one worth keeping, so give the
+ // canonical id a copy of it.
+ var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false);
+ sourceId = source.Id;
+
+ _libraryManager.CreateItem(
+ new UserView
+ {
+ Path = path,
+ Id = canonicalId,
+ DateCreated = source.DateCreated,
+ DateModified = source.DateModified,
+ Name = source.Name,
+ ViewType = viewType,
+ ForcedSortName = source.ForcedSortName
+ },
+ null);
+ }
+
+ var reparented = await dbContext.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.ParentId!.Value)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.BaseItems
+ .Where(e => e.TopParentId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.TopParentId!.Value)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false);
+ await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false);
+
+ // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last.
+ await dbContext.BaseItems
+ .WhereOneOrMany(staleIds, e => e.Id)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}",
+ reparented,
+ staleIds.Length,
+ viewType,
+ canonicalId);
+ }
+
+ private async Task<UserView> PickSourceAsync(
+ JellyfinDbContext dbContext,
+ IReadOnlyList<UserView> stale,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ var childCounts = await dbContext.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.ParentId!.Value)
+ .GroupBy(e => e.ParentId!.Value)
+ .Select(g => new { ParentId = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken)
+ .ConfigureAwait(false);
+
+ return stale
+ .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id))
+ .ThenBy(view => view.DateCreated)
+ .First();
+ }
+
+ private static async Task MoveUserSettingsAsync(
+ JellyfinDbContext dbContext,
+ Guid canonicalId,
+ Guid sourceId,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ // Everything below is keyed by the view's id, and a view holding no children still holds the
+ // ordering it was given and whether it was hidden. Only the view that was promoted can hand
+ // those over - the rest would collide on the one row per user, item and client - so the others
+ // are dropped instead.
+ var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray();
+
+ if (!sourceId.Equals(Guid.Empty))
+ {
+ var moved = new[] { sourceId };
+
+ await dbContext.DisplayPreferences
+ .WhereOneOrMany(moved, e => e.ItemId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.ItemDisplayPreferences
+ .WhereOneOrMany(moved, e => e.ItemId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.CustomItemDisplayPreferences
+ .WhereOneOrMany(moved, e => e.ItemId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (dropped.Length > 0)
+ {
+ await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var stale = staleIds.ToHashSet();
+ var preferences = await dbContext.Preferences
+ .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var changed = false;
+
+ foreach (var preference in preferences)
+ {
+ var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var rewritten = new List<string>(values.Length);
+ var seen = new HashSet<Guid>();
+ var touched = false;
+
+ foreach (var value in values)
+ {
+ // Clients write these in both the dashed and the plain form, so compare them parsed.
+ if (!Guid.TryParse(value, out var parsed))
+ {
+ rewritten.Add(value);
+ continue;
+ }
+
+ var isStale = stale.Contains(parsed);
+ if (isStale)
+ {
+ parsed = canonicalId;
+ touched = true;
+ }
+
+ // The same view can be listed twice once both of its ids point at the same place.
+ if (!seen.Add(parsed))
+ {
+ continue;
+ }
+
+ rewritten.Add(isStale
+ ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture)
+ : value);
+ }
+
+ if (!touched)
+ {
+ continue;
+ }
+
+ preference.Value = string.Join(',', rewritten);
+ changed = true;
+ }
+
+ if (changed)
+ {
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private static async Task MoveAncestorsAsync(
+ JellyfinDbContext dbContext,
+ Guid canonicalId,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ var items = await dbContext.AncestorIds
+ .WhereOneOrMany(staleIds, e => e.ParentItemId)
+ .Select(e => e.ItemId)
+ .Distinct()
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.AncestorIds
+ .WhereOneOrMany(staleIds, e => e.ParentItemId)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (items.Count == 0)
+ {
+ return;
+ }
+
+ // The pair is the primary key, so anything already recorded against the canonical view stays put.
+ var existing = await dbContext.AncestorIds
+ .Where(e => e.ParentItemId.Equals(canonicalId))
+ .Select(e => e.ItemId)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ foreach (var itemId in items.Except(existing))
+ {
+ dbContext.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = canonicalId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+}