Fix tests

This commit is contained in:
Shadowghost
2026-08-25 20:29:10 +02:00
parent 422b2bb3d9
commit a81b90f570
7 changed files with 362 additions and 11 deletions
@@ -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);
@@ -2927,7 +2927,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;
@@ -2951,6 +2952,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)
{
@@ -2971,7 +2979,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));
@@ -3001,6 +3011,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;
@@ -3102,7 +3117,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;
@@ -3136,9 +3151,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();
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
+1 -1
View File
@@ -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);
}
@@ -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(