aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-25 18:26:09 -0400
committerGitHub <noreply@github.com>2026-08-25 18:26:09 -0400
commite79e2cba276dac1998880869015326a750d38f67 (patch)
tree27db848bdc820cad14eb0c1cdcb07ebae888798f
parentf3298eee69d0c6cdce3a70b6d3a8333f257017c6 (diff)
parenta81b90f570f681d0f5088637d03bb14a54ef6e2e (diff)
Merge pull request #17714 from Shadowghost/fix-localized-view-ids
Stop deriving user view ids from their localized name
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs3
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs24
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs2
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs6
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs334
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs2
7 files changed, 362 insertions, 11 deletions
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 295efd456c..84d50f5121 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections
SaveLocalMetadata = true
};
- var name = _localizationManager.GetLocalizedString("Collections");
+ // This names a library for the whole server, so ignore the requesting client's language.
+ var name = _localizationManager.GetServerLocalizedString("Collections");
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 789823ddbf..6c5ff6e8c7 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -2933,7 +2933,8 @@ namespace Emby.Server.Implementations.Library
"views",
_fileSystem.GetValidFilename(viewType.ToString()));
- var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView));
+ // The display name is localized, so it must not take part in the id.
+ var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
var item = GetItemById(id) as UserView;
@@ -2957,6 +2958,13 @@ namespace Emby.Server.Implementations.Library
refresh = true;
}
+ else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
+ {
+ item.Name = name;
+ item.ForcedSortName = sortName;
+
+ refresh = true;
+ }
if (refresh)
{
@@ -2977,7 +2985,9 @@ namespace Emby.Server.Implementations.Library
var parentIdString = parentId.IsEmpty()
? null
: parentId.ToString("N", CultureInfo.InvariantCulture);
- var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
+
+ // The name is either localized (grouped views) or the library folder's own name.
+ var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
var id = GetNewItemId(idValues, typeof(UserView));
@@ -3007,6 +3017,11 @@ namespace Emby.Server.Implementations.Library
isNew = true;
}
+ else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
+ {
+ item.Name = name;
+ item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
+ }
var lastRefreshedUtc = item.DateLastRefreshed;
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
@@ -3108,7 +3123,7 @@ namespace Emby.Server.Implementations.Library
var parentIdString = parentId.IsEmpty()
? null
: parentId.ToString("N", CultureInfo.InvariantCulture);
- var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
+ var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
if (!string.IsNullOrEmpty(uniqueId))
{
idValues += uniqueId;
@@ -3142,9 +3157,10 @@ namespace Emby.Server.Implementations.Library
isNew = true;
}
- if (viewType != item.ViewType)
+ if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal))
{
item.ViewType = viewType;
+ item.Name = name;
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index 6e9a38fd34..6624d0125f 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
args.LibraryOptions.SeasonZeroDisplayName :
string.Format(
CultureInfo.InvariantCulture,
- _localization.GetLocalizedString("NameSeasonNumber"),
+ _localization.GetServerLocalizedString("NameSeasonNumber"),
seasonNumber,
args.LibraryOptions.PreferredMetadataLanguage);
}
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 49d76e195d..47b3891901 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
if (_config.Configuration.EnableFolderView)
{
- var name = _localizationManager.GetLocalizedString("Folders");
+ var name = _localizationManager.GetServerLocalizedString("Folders");
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty));
}
@@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library
public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName)
{
- var name = _localizationManager.GetLocalizedString(localizationKey);
+ var name = _localizationManager.GetServerLocalizedString(localizationKey);
return GetUserSubViewWithName(name, parentId, type, sortName);
}
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
return GetUserView((Folder)parents[0], viewType, string.Empty);
}
- var name = _localizationManager.GetLocalizedString(localizationKey);
+ var name = _localizationManager.GetServerLocalizedString(localizationKey);
return _libraryManager.GetNamedView(user, name, viewType, sortName);
}
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);
+ }
+}
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 173d3c3e8e..2edf7681db 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -1262,7 +1262,7 @@ namespace Jellyfin.LiveTv
public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken)
{
- var name = _localization.GetLocalizedString("HeaderLiveTV");
+ var name = _localization.GetServerLocalizedString("HeaderLiveTV");
return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
index 133a3f7d47..feb2d8a625 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -21,7 +21,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
var localizationMock = new Mock<ILocalizationManager>();
localizationMock
- .Setup(l => l.GetLocalizedString(It.IsAny<string>()))
+ .Setup(l => l.GetServerLocalizedString(It.IsAny<string>()))
.Returns("Season {0}");
_resolver = new SeasonResolver(