aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api/Controllers/LibraryStructureController.cs
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Api/Controllers/LibraryStructureController.cs')
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs217
1 files changed, 176 insertions, 41 deletions
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index e46795554b..9e6bfde085 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -10,11 +10,17 @@ 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;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
@@ -33,6 +39,8 @@ public class LibraryStructureController : BaseJellyfinApiController
private readonly IServerApplicationPaths _appPaths;
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.
@@ -40,14 +48,20 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
/// <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)
+ ILibraryMonitor libraryMonitor,
+ IDirectoryService directoryService,
+ IUserManager userManager)
{
_appPaths = serverConfigurationManager.ApplicationPaths;
_libraryManager = libraryManager;
_libraryMonitor = libraryMonitor;
+ _directoryService = directoryService;
+ _userManager = userManager;
}
/// <summary>
@@ -122,15 +136,17 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="newName">The new name.</param>
/// <param name="refreshLibrary">Whether to refresh the library.</param>
/// <response code="204">Folder renamed.</response>
+ /// <response code="400">The new name is not a valid library name.</response>
/// <response code="404">Library doesn't exist.</response>
/// <response code="409">Library already exists.</response>
- /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
+ /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
/// <exception cref="ArgumentNullException">The new name may not be null.</exception>
[HttpPost("Name")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [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)
@@ -147,10 +163,15 @@ public class LibraryStructureController : BaseJellyfinApiController
var rootFolderPath = _appPaths.DefaultUserViewsPath;
- var currentPath = Path.Combine(rootFolderPath, name);
- var newPath = Path.Combine(rootFolderPath, newName);
+ // Both names are caller supplied, so they have to be confined to the libraries root.
+ var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName);
+ if (newPath is null)
+ {
+ return BadRequest("The new name is not a valid library name.");
+ }
- if (!Directory.Exists(currentPath))
+ var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name);
+ if (currentPath is null || !Directory.Exists(currentPath))
{
return NotFound("The media collection does not exist.");
}
@@ -160,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
@@ -170,52 +197,160 @@ public class LibraryStructureController : BaseJellyfinApiController
var tempPath = Path.Combine(
rootFolderPath,
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
- Directory.Move(currentPath, tempPath);
+ _directoryService.Move(currentPath, tempPath);
currentPath = tempPath;
}
- Directory.Move(currentPath, newPath);
+ _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
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
- }
-
- _libraryManager.ClearIgnoreRuleCache();
+ await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
+ await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
}
- 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();
- }
- });
+ }
+ 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>
@@ -247,7 +382,7 @@ public class LibraryStructureController : BaseJellyfinApiController
// No need to start if scanning the library because it will handle it
if (refreshLibrary)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ _libraryManager.QueueLibraryScan();
}
else
{
@@ -314,7 +449,7 @@ public class LibraryStructureController : BaseJellyfinApiController
// No need to start if scanning the library because it will handle it
if (refreshLibrary)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ _libraryManager.QueueLibraryScan();
}
else
{