diff options
4 files changed, 319 insertions, 45 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2d62fdab48..b4f6f57bf6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3052,14 +3052,7 @@ namespace Emby.Server.Implementations.Library CollectionType? viewType, string sortName) { - var parentIdString = parentId.IsEmpty() - ? null - : parentId.ToString("N", CultureInfo.InvariantCulture); - - // 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)); + var id = GetNamedViewId(user, parentId, viewType); var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture)); @@ -3117,6 +3110,18 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType) + { + var parentIdString = parentId.IsEmpty() + ? null + : parentId.ToString("N", CultureInfo.InvariantCulture); + + // 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); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetShadowView( BaseItem parent, CollectionType? viewType, @@ -3126,10 +3131,7 @@ namespace Emby.Server.Implementations.Library var name = parent.Name; var parentId = parent.Id; - - var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); - - var id = GetNewItemId(idValues, typeof(UserView)); + var id = GetShadowViewId(name, parentId, viewType); var path = parent.Path; @@ -3181,6 +3183,13 @@ namespace Emby.Server.Implementations.Library return item; } + public Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType) + { + var idValues = "38_namedview_" + name + parentId + (viewType?.ToString() ?? string.Empty); + + return GetNewItemId(idValues, typeof(UserView)); + } + public UserView GetNamedView( string name, Guid parentId, diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 7f63e410a5..9e6bfde085 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -10,6 +10,10 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -36,6 +40,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; private readonly IDirectoryService _directoryService; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. @@ -44,16 +49,19 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, - IDirectoryService directoryService) + IDirectoryService directoryService, + IUserManager userManager) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _directoryService = directoryService; + _userManager = userManager; } /// <summary> @@ -138,7 +146,7 @@ public class LibraryStructureController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult RenameVirtualFolder( + public async Task<ActionResult> RenameVirtualFolder( [FromQuery] string? name, [FromQuery] string? newName, [FromQuery] bool refreshLibrary = false) @@ -173,6 +181,12 @@ public class LibraryStructureController : BaseJellyfinApiController return Conflict($"The media library already exists at {newPath}."); } + var oldLibrary = FindLibrary(currentPath); + // Use the stored ID when possible. The case sensitivity setting may have changed since the library was created. + var oldLibraryId = oldLibrary?.Id ?? _libraryManager.GetNewItemId(currentPath, typeof(CollectionFolder)); + var oldLibraryName = oldLibrary?.Name ?? Path.GetFileName(currentPath); + var newLibraryId = _libraryManager.GetNewItemId(newPath, typeof(CollectionFolder)); + _libraryMonitor.Stop(); try @@ -188,47 +202,155 @@ public class LibraryStructureController : BaseJellyfinApiController } _directoryService.Move(currentPath, newPath); + + await ReplaceLibraryIdInUserPreferencesAsync( + oldLibraryId, + newLibraryId, + oldLibraryName, + Path.GetFileName(newPath), + oldLibrary?.CollectionType).ConfigureAwait(false); } finally { CollectionFolder.OnCollectionFolderChange(); - Task.Run(async () => + _ = Task.Run(() => RefreshAfterRenameAsync(newPath, refreshLibrary)); + } + + return NoContent(); + } + + private async Task RefreshAfterRenameAsync(string newPath, bool refreshLibrary) + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); + var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); + if (newLib is CollectionFolder folder) { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) + _libraryManager.ClearIgnoreRuleCache(); + foreach (var child in folder.GetPhysicalFolders()) { - await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); - if (newLib is CollectionFolder folder) - { - _libraryManager.ClearIgnoreRuleCache(); - foreach (var child in folder.GetPhysicalFolders()) - { - await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); - await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); - } - } - else - { - _libraryManager.ClearIgnoreRuleCache(); - // We don't know if this one can be validated individually, trigger a new validation - _libraryManager.QueueLibraryScan(); - } - - _libraryManager.ClearIgnoreRuleCache(); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); + await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } - }); + } + else + { + _libraryManager.ClearIgnoreRuleCache(); + // We don't know if this one can be validated individually, trigger a new validation + _libraryManager.QueueLibraryScan(); + } + + _libraryManager.ClearIgnoreRuleCache(); } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + } - return NoContent(); + private CollectionFolder? FindLibrary(string path) + => _libraryManager.GetUserRootFolder().Children + .OfType<CollectionFolder>() + .FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase)); + + private async Task ReplaceLibraryIdInUserPreferencesAsync( + Guid oldId, + Guid newId, + string oldName, + string newName, + CollectionType? viewType) + { + var shadowView = ( + Old: _libraryManager.GetShadowViewId(oldName, oldId, viewType), + New: _libraryManager.GetShadowViewId(newName, newId, viewType)); + + if (oldId.Equals(newId) && shadowView.Old.Equals(shadowView.New)) + { + return; + } + + PreferenceKind[] libraryPreferences = + [ + PreferenceKind.EnabledFolders, + PreferenceKind.BlockedMediaFolders, + PreferenceKind.EnableContentDeletionFromFolders, + PreferenceKind.LatestItemExcludes, + PreferenceKind.MyMediaExcludes, + PreferenceKind.GroupedFolders, + PreferenceKind.OrderedViews + ]; + + // These lists hold what the "My Media" row shows, which includes the views built from a library. + PreferenceKind[] viewPreferences = [PreferenceKind.MyMediaExcludes, PreferenceKind.OrderedViews]; + + foreach (var user in _userManager.GetUsers()) + { + var libraryMap = new Dictionary<Guid, Guid> { [oldId] = newId }; + var viewMap = new Dictionary<Guid, Guid>(libraryMap) + { + [shadowView.Old] = shadowView.New, + [_libraryManager.GetNamedViewId(user, oldId, viewType)] = _libraryManager.GetNamedViewId(user, newId, viewType) + }; + + var changed = false; + + foreach (var kind in libraryPreferences) + { + changed |= ReplaceIds(user, kind, viewPreferences.Contains(kind) ? viewMap : libraryMap); + } + + if (changed) + { + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + } + } + } + + private static bool ReplaceIds(User user, PreferenceKind kind, Dictionary<Guid, Guid> map) + { + var values = user.GetPreference(kind); + 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 isReplaced = map.TryGetValue(parsed, out var replacement) && !replacement.Equals(parsed); + if (isReplaced) + { + parsed = replacement; + touched = true; + } + + if (!seen.Add(parsed)) + { + touched = true; + continue; + } + + var format = value.Contains('-', StringComparison.Ordinal) ? "D" : "N"; + rewritten.Add(isReplaced ? parsed.ToString(format, CultureInfo.InvariantCulture) : value); + } + + if (touched) + { + user.SetPreference(kind, rewritten.ToArray()); + } + + return touched; } /// <summary> diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 82de3546f0..ae15c75350 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -484,6 +484,15 @@ namespace MediaBrowser.Controller.Library string uniqueId); /// <summary> + /// Gets the identifier of a named user view. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="parentId">The parent identifier.</param> + /// <param name="viewType">Type of the view.</param> + /// <returns>The named view identifier.</returns> + Guid GetNamedViewId(User user, Guid parentId, CollectionType? viewType); + + /// <summary> /// Gets the shadow view. /// </summary> /// <param name="parent">The parent.</param> @@ -496,6 +505,15 @@ namespace MediaBrowser.Controller.Library string sortName); /// <summary> + /// Gets the identifier of a shadow view. + /// </summary> + /// <param name="name">The view name.</param> + /// <param name="parentId">The parent identifier.</param> + /// <param name="viewType">Type of the view.</param> + /// <returns>The shadow view identifier.</returns> + Guid GetShadowViewId(string name, Guid parentId, CollectionType? viewType); + + /// <summary> /// Gets the season number from path. /// </summary> /// <param name="path">The path.</param> diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 79b9d1e2c5..3d0840b776 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -1,12 +1,19 @@ using System; +using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -218,4 +225,122 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl using var response = await client.DeleteAsync("Library/VirtualFolders?name=test&refreshLibrary=true", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_UserHasLibraryPreferences_RemapsLibraryId() + { + const string Name = "rename-prefs-old"; + const string NewName = "rename-prefs-new"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService<ILibraryManager>(); + var userManager = _factory.Services.GetRequiredService<IUserManager>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&collectionType=movies&refreshLibrary=true", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, NewName), typeof(CollectionFolder)); + Assert.NotEqual(oldId, newId); + + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(oldId)); + + var otherId = Guid.NewGuid(); + var user = await userManager.CreateUserAsync("rename-prefs-user"); + user.SetPermission(PermissionKind.EnableAllFolders, false); + user.SetPreference(PreferenceKind.EnabledFolders, [otherId, oldId, newId]); + user.SetPreference(PreferenceKind.BlockedMediaFolders, [oldId]); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, [oldId, otherId]); + user.SetPreference(PreferenceKind.LatestItemExcludes, [oldId]); + + // The views built from the library take its id and name as input, so they move with it. + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var oldUserViewId = libraryManager.GetNamedView(user, oldLibrary.Name, oldId, oldLibrary.CollectionType, string.Empty).Id; + user.SetPreference(PreferenceKind.MyMediaExcludes, [otherId.ToString(), oldId.ToString(), oldShadowId.ToString("N", CultureInfo.InvariantCulture)]); + user.SetPreference(PreferenceKind.GroupedFolders, [oldId]); + user.SetPreference(PreferenceKind.OrderedViews, [otherId.ToString(), oldUserViewId.ToString(), oldShadowId.ToString(), oldId.ToString()]); + user.SetPreference(PreferenceKind.BlockedTags, ["keep"]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={NewName}&refreshLibrary=true", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + var reloaded = userManager.GetUserById(user.Id)!; + Assert.Equal([otherId, newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders)); + Assert.Equal([newId, otherId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes)); + // The endpoint refreshes in the background; make sure the renamed library has been picked up. + await libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true); + var newLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(newId)); + var newShadowId = libraryManager.GetShadowView(newLibrary, newLibrary.CollectionType, string.Empty).Id; + var newUserViewId = libraryManager.GetNamedView(reloaded, newLibrary.Name, newId, newLibrary.CollectionType, string.Empty).Id; + Assert.NotEqual(oldShadowId, newShadowId); + Assert.NotEqual(oldUserViewId, newUserViewId); + Assert.Equal( + [otherId.ToString(), newId.ToString(), newShadowId.ToString("N", CultureInfo.InvariantCulture)], + reloaded.GetPreference(PreferenceKind.MyMediaExcludes)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders)); + Assert.Equal( + [otherId.ToString(), newUserViewId.ToString(), newShadowId.ToString(), newId.ToString()], + reloaded.GetPreference(PreferenceKind.OrderedViews)); + Assert.Equal(["keep"], reloaded.GetPreference(PreferenceKind.BlockedTags)); + + // With the block list cleared, the enabled list alone must let the user see the renamed library. + reloaded.SetPreference(PreferenceKind.BlockedMediaFolders, Array.Empty<Guid>()); + await userManager.UpdateUserAsync(reloaded); + Assert.True(newLibrary.IsVisible(userManager.GetUserById(user.Id)!)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={NewName}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_CapitalizationOnly_LeavesPreferencesAlone() + { + const string Name = "rename-case-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService<ILibraryManager>(); + var userManager = _factory.Services.GetRequiredService<IUserManager>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name.ToUpperInvariant()), typeof(CollectionFolder)); + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(oldId)); + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var newShadowId = libraryManager.GetShadowViewId(Name.ToUpperInvariant(), newId, oldLibrary.CollectionType); + + var user = await userManager.CreateUserAsync("rename-case-user"); + user.SetPreference(PreferenceKind.EnabledFolders, [oldId]); + user.SetPreference(PreferenceKind.MyMediaExcludes, [oldShadowId]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={Name.ToUpperInvariant()}&refreshLibrary=false", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + Assert.Equal([newId], userManager.GetUserById(user.Id)!.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders)); + Assert.Equal([newShadowId], userManager.GetUserById(user.Id)!.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name.ToUpperInvariant()}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } } |
