< Summary - Jellyfin

Information
Class: Jellyfin.Api.Controllers.LibraryStructureController
Assembly: Jellyfin.Api
File(s): /srv/git/jellyfin/Jellyfin.Api/Controllers/LibraryStructureController.cs
Line coverage
88%
Covered lines: 161
Uncovered lines: 20
Coverable lines: 181
Total lines: 500
Line coverage: 88.9%
Branch coverage
75%
Covered branches: 59
Total branches: 78
Branch coverage: 75.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 55.3% (72/130) Branch coverage: 50% (16/32) Total lines: 36510/29/2026 - 12:17:11 AM Line coverage: 88.9% (161/181) Branch coverage: 75.6% (59/78) Total lines: 500 5/16/2026 - 12:15:55 AM Line coverage: 55.3% (72/130) Branch coverage: 50% (16/32) Total lines: 36510/29/2026 - 12:17:11 AM Line coverage: 88.9% (161/181) Branch coverage: 75.6% (59/78) Total lines: 500

Coverage delta

Coverage delta 34 -34

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetVirtualFolders()100%11100%
AddVirtualFolder()62.5%8883.33%
RemoveVirtualFolder()100%11100%
RenameVirtualFolder()79.16%242497.22%
RefreshAfterRenameAsync()66.66%7666.66%
FindLibrary(...)100%11100%
ReplaceLibraryIdInUserPreferencesAsync()83.33%121296.55%
ReplaceIds(...)93.75%161690%
AddMediaPath(...)75%4490.47%
UpdateMediaPath(...)0%620%
RemoveMediaPath(...)100%1190.9%
UpdateLibraryOptions(...)50%6677.77%

File(s)

/srv/git/jellyfin/Jellyfin.Api/Controllers/LibraryStructureController.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.ComponentModel.DataAnnotations;
 4using System.Globalization;
 5using System.IO;
 6using System.Linq;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.Api.Extensions;
 10using Jellyfin.Api.Helpers;
 11using Jellyfin.Api.ModelBinders;
 12using Jellyfin.Api.Models.LibraryStructureDto;
 13using Jellyfin.Data;
 14using Jellyfin.Data.Enums;
 15using Jellyfin.Database.Implementations.Entities;
 16using Jellyfin.Database.Implementations.Enums;
 17using MediaBrowser.Common.Api;
 18using MediaBrowser.Controller;
 19using MediaBrowser.Controller.Configuration;
 20using MediaBrowser.Controller.Entities;
 21using MediaBrowser.Controller.IO;
 22using MediaBrowser.Controller.Library;
 23using MediaBrowser.Controller.Providers;
 24using MediaBrowser.Model.Configuration;
 25using MediaBrowser.Model.Entities;
 26using Microsoft.AspNetCore.Authorization;
 27using Microsoft.AspNetCore.Http;
 28using Microsoft.AspNetCore.Mvc;
 29
 30namespace Jellyfin.Api.Controllers;
 31
 32/// <summary>
 33/// The library structure controller.
 34/// </summary>
 35[Route("Library/VirtualFolders")]
 36[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
 37public class LibraryStructureController : BaseJellyfinApiController
 38{
 39    private readonly IServerApplicationPaths _appPaths;
 40    private readonly ILibraryManager _libraryManager;
 41    private readonly ILibraryMonitor _libraryMonitor;
 42    private readonly IDirectoryService _directoryService;
 43    private readonly IUserManager _userManager;
 44
 45    /// <summary>
 46    /// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
 47    /// </summary>
 48    /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param
 49    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
 50    /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
 51    /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param>
 52    /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
 3553    public LibraryStructureController(
 3554        IServerConfigurationManager serverConfigurationManager,
 3555        ILibraryManager libraryManager,
 3556        ILibraryMonitor libraryMonitor,
 3557        IDirectoryService directoryService,
 3558        IUserManager userManager)
 59    {
 3560        _appPaths = serverConfigurationManager.ApplicationPaths;
 3561        _libraryManager = libraryManager;
 3562        _libraryMonitor = libraryMonitor;
 3563        _directoryService = directoryService;
 3564        _userManager = userManager;
 3565    }
 66
 67    /// <summary>
 68    /// Gets all virtual folders.
 69    /// </summary>
 70    /// <response code="200">Virtual folders retrieved.</response>
 71    /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns>
 72    [HttpGet]
 73    [ProducesResponseType(StatusCodes.Status200OK)]
 74    public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders()
 75    {
 176        return _libraryManager.GetVirtualFolders(true);
 77    }
 78
 79    /// <summary>
 80    /// Adds a virtual folder.
 81    /// </summary>
 82    /// <param name="name">The name of the virtual folder.</param>
 83    /// <param name="collectionType">The type of the collection.</param>
 84    /// <param name="paths">The paths of the virtual folder.</param>
 85    /// <param name="libraryOptionsDto">The library options.</param>
 86    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 87    /// <response code="204">Folder added.</response>
 88    /// <returns>A <see cref="NoContentResult"/>.</returns>
 89    [HttpPost]
 90    [ProducesResponseType(StatusCodes.Status204NoContent)]
 91    public async Task<ActionResult> AddVirtualFolder(
 92        [FromQuery]
 93        [RegularExpression(@"^(?:\S(?:.*\S)?)$", ErrorMessage = "Library name cannot be empty or have leading/trailing s
 94        string name,
 95        [FromQuery] CollectionTypeOptions? collectionType,
 96        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] paths,
 97        [FromBody] AddVirtualFolderDto? libraryOptionsDto,
 98        [FromQuery] bool refreshLibrary = false)
 99    {
 5100        var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
 101
 5102        if (paths is not null && paths.Length > 0)
 103        {
 0104            libraryOptions.PathInfos = Array.ConvertAll(paths, i => new MediaPathInfo(i));
 105        }
 106
 5107        await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(fals
 108
 5109        return NoContent();
 5110    }
 111
 112    /// <summary>
 113    /// Removes a virtual folder.
 114    /// </summary>
 115    /// <param name="name">The name of the folder.</param>
 116    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 117    /// <response code="204">Folder removed.</response>
 118    /// <response code="404">Folder not found.</response>
 119    /// <returns>A <see cref="NoContentResult"/>.</returns>
 120    [HttpDelete]
 121    [ProducesResponseType(StatusCodes.Status204NoContent)]
 122    public async Task<ActionResult> RemoveVirtualFolder(
 123        [FromQuery] string name,
 124        [FromQuery] bool refreshLibrary = false)
 125    {
 126        // TODO: refactor! this relies on an FileNotFound exception to return NotFound when attempting to remove a libra
 10127        await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
 128
 4129        return NoContent();
 4130    }
 131
 132    /// <summary>
 133    /// Renames a virtual folder.
 134    /// </summary>
 135    /// <param name="name">The name of the virtual folder.</param>
 136    /// <param name="newName">The new name.</param>
 137    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 138    /// <response code="204">Folder renamed.</response>
 139    /// <response code="400">The new name is not a valid library name.</response>
 140    /// <response code="404">Library doesn't exist.</response>
 141    /// <response code="409">Library already exists.</response>
 142    /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid
 143    /// <exception cref="ArgumentNullException">The new name may not be null.</exception>
 144    [HttpPost("Name")]
 145    [ProducesResponseType(StatusCodes.Status204NoContent)]
 146    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 147    [ProducesResponseType(StatusCodes.Status404NotFound)]
 148    [ProducesResponseType(StatusCodes.Status409Conflict)]
 149    public async Task<ActionResult> RenameVirtualFolder(
 150        [FromQuery] string? name,
 151        [FromQuery] string? newName,
 152        [FromQuery] bool refreshLibrary = false)
 153    {
 13154        if (string.IsNullOrWhiteSpace(name))
 155        {
 1156            throw new ArgumentNullException(nameof(name));
 157        }
 158
 12159        if (string.IsNullOrWhiteSpace(newName))
 160        {
 1161            throw new ArgumentNullException(nameof(newName));
 162        }
 163
 11164        var rootFolderPath = _appPaths.DefaultUserViewsPath;
 165
 166        // Both names are caller supplied, so they have to be confined to the libraries root.
 11167        var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName);
 11168        if (newPath is null)
 169        {
 5170            return BadRequest("The new name is not a valid library name.");
 171        }
 172
 6173        var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name);
 6174        if (currentPath is null || !Directory.Exists(currentPath))
 175        {
 4176            return NotFound("The media collection does not exist.");
 177        }
 178
 2179        if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
 180        {
 0181            return Conflict($"The media library already exists at {newPath}.");
 182        }
 183
 2184        var oldLibrary = FindLibrary(currentPath);
 185        // Use the stored ID when possible. The case sensitivity setting may have changed since the library was created.
 2186        var oldLibraryId = oldLibrary?.Id ?? _libraryManager.GetNewItemId(currentPath, typeof(CollectionFolder));
 2187        var oldLibraryName = oldLibrary?.Name ?? Path.GetFileName(currentPath);
 2188        var newLibraryId = _libraryManager.GetNewItemId(newPath, typeof(CollectionFolder));
 189
 2190        _libraryMonitor.Stop();
 191
 192        try
 193        {
 194            // Changing capitalization. Handle windows case insensitivity
 2195            if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
 196            {
 1197                var tempPath = Path.Combine(
 1198                    rootFolderPath,
 1199                    Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
 1200                _directoryService.Move(currentPath, tempPath);
 1201                currentPath = tempPath;
 202            }
 203
 2204            _directoryService.Move(currentPath, newPath);
 205
 2206            await ReplaceLibraryIdInUserPreferencesAsync(
 2207                oldLibraryId,
 2208                newLibraryId,
 2209                oldLibraryName,
 2210                Path.GetFileName(newPath),
 2211                oldLibrary?.CollectionType).ConfigureAwait(false);
 2212        }
 213        finally
 214        {
 2215            CollectionFolder.OnCollectionFolderChange();
 216
 2217            _ = Task.Run(() => RefreshAfterRenameAsync(newPath, refreshLibrary));
 218        }
 219
 2220        return NoContent();
 11221    }
 222
 223    private async Task RefreshAfterRenameAsync(string newPath, bool refreshLibrary)
 224    {
 225        // No need to start if scanning the library because it will handle it
 2226        if (refreshLibrary)
 227        {
 1228            await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
 1229            var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringC
 1230            if (newLib is CollectionFolder folder)
 231            {
 1232                _libraryManager.ClearIgnoreRuleCache();
 2233                foreach (var child in folder.GetPhysicalFolders())
 234                {
 0235                    await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
 0236                    await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
 0237                }
 238            }
 239            else
 240            {
 0241                _libraryManager.ClearIgnoreRuleCache();
 242                // We don't know if this one can be validated individually, trigger a new validation
 0243                _libraryManager.QueueLibraryScan();
 244            }
 245
 1246            _libraryManager.ClearIgnoreRuleCache();
 247        }
 248        else
 249        {
 250            // Need to add a delay here or directory watchers may still pick up the changes
 251            // Have to block here to allow exceptions to bubble
 1252            await Task.Delay(1000).ConfigureAwait(false);
 1253            _libraryMonitor.Start();
 254        }
 2255    }
 256
 257    private CollectionFolder? FindLibrary(string path)
 2258        => _libraryManager.GetUserRootFolder().Children
 2259            .OfType<CollectionFolder>()
 2260            .FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase));
 261
 262    private async Task ReplaceLibraryIdInUserPreferencesAsync(
 263        Guid oldId,
 264        Guid newId,
 265        string oldName,
 266        string newName,
 267        CollectionType? viewType)
 268    {
 2269        var shadowView = (
 2270            Old: _libraryManager.GetShadowViewId(oldName, oldId, viewType),
 2271            New: _libraryManager.GetShadowViewId(newName, newId, viewType));
 272
 2273        if (oldId.Equals(newId) && shadowView.Old.Equals(shadowView.New))
 274        {
 0275            return;
 276        }
 277
 2278        PreferenceKind[] libraryPreferences =
 2279        [
 2280            PreferenceKind.EnabledFolders,
 2281            PreferenceKind.BlockedMediaFolders,
 2282            PreferenceKind.EnableContentDeletionFromFolders,
 2283            PreferenceKind.LatestItemExcludes,
 2284            PreferenceKind.MyMediaExcludes,
 2285            PreferenceKind.GroupedFolders,
 2286            PreferenceKind.OrderedViews
 2287        ];
 288
 289        // These lists hold what the "My Media" row shows, which includes the views built from a library.
 2290        PreferenceKind[] viewPreferences = [PreferenceKind.MyMediaExcludes, PreferenceKind.OrderedViews];
 291
 14292        foreach (var user in _userManager.GetUsers())
 293        {
 5294            var libraryMap = new Dictionary<Guid, Guid> { [oldId] = newId };
 5295            var viewMap = new Dictionary<Guid, Guid>(libraryMap)
 5296            {
 5297                [shadowView.Old] = shadowView.New,
 5298                [_libraryManager.GetNamedViewId(user, oldId, viewType)] = _libraryManager.GetNamedViewId(user, newId, vi
 5299            };
 300
 5301            var changed = false;
 302
 80303            foreach (var kind in libraryPreferences)
 304            {
 35305                changed |= ReplaceIds(user, kind, viewPreferences.Contains(kind) ? viewMap : libraryMap);
 306            }
 307
 5308            if (changed)
 309            {
 2310                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 311            }
 312        }
 2313    }
 314
 315    private static bool ReplaceIds(User user, PreferenceKind kind, Dictionary<Guid, Guid> map)
 316    {
 35317        var values = user.GetPreference(kind);
 35318        var rewritten = new List<string>(values.Length);
 35319        var seen = new HashSet<Guid>();
 35320        var touched = false;
 321
 108322        foreach (var value in values)
 323        {
 324            // Clients write these in both the dashed and the plain form, so compare them parsed.
 19325            if (!Guid.TryParse(value, out var parsed))
 326            {
 0327                rewritten.Add(value);
 0328                continue;
 329            }
 330
 19331            var isReplaced = map.TryGetValue(parsed, out var replacement) && !replacement.Equals(parsed);
 19332            if (isReplaced)
 333            {
 12334                parsed = replacement;
 12335                touched = true;
 336            }
 337
 19338            if (!seen.Add(parsed))
 339            {
 1340                touched = true;
 1341                continue;
 342            }
 343
 18344            var format = value.Contains('-', StringComparison.Ordinal) ? "D" : "N";
 18345            rewritten.Add(isReplaced ? parsed.ToString(format, CultureInfo.InvariantCulture) : value);
 346        }
 347
 35348        if (touched)
 349        {
 9350            user.SetPreference(kind, rewritten.ToArray());
 351        }
 352
 35353        return touched;
 354    }
 355
 356    /// <summary>
 357    /// Add a media path to a library.
 358    /// </summary>
 359    /// <param name="mediaPathDto">The media path dto.</param>
 360    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 361    /// <returns>A <see cref="NoContentResult"/>.</returns>
 362    /// <response code="204">Media path added.</response>
 363    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
 364    [HttpPost("Paths")]
 365    [ProducesResponseType(StatusCodes.Status204NoContent)]
 366    public ActionResult AddMediaPath(
 367        [FromBody, Required] MediaPathDto mediaPathDto,
 368        [FromQuery] bool refreshLibrary = false)
 369    {
 1370        _libraryMonitor.Stop();
 371
 372        try
 373        {
 1374            var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException(
 375
 1376            _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath);
 0377        }
 378        finally
 379        {
 1380            Task.Run(async () =>
 1381            {
 1382                // No need to start if scanning the library because it will handle it
 1383                if (refreshLibrary)
 1384                {
 1385                    _libraryManager.QueueLibraryScan();
 1386                }
 1387                else
 1388                {
 1389                    // Need to add a delay here or directory watchers may still pick up the changes
 1390                    // Have to block here to allow exceptions to bubble
 1391                    await Task.Delay(1000).ConfigureAwait(false);
 1392                    _libraryMonitor.Start();
 1393                }
 1394            });
 1395        }
 396
 0397        return NoContent();
 398    }
 399
 400    /// <summary>
 401    /// Updates a media path.
 402    /// </summary>
 403    /// <param name="mediaPathRequestDto">The name of the library and path infos.</param>
 404    /// <returns>A <see cref="NoContentResult"/>.</returns>
 405    /// <response code="204">Media path updated.</response>
 406    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
 407    [HttpPost("Paths/Update")]
 408    [ProducesResponseType(StatusCodes.Status204NoContent)]
 409    public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto)
 410    {
 0411        if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name))
 412        {
 0413            throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty");
 414        }
 415
 0416        _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo);
 0417        return NoContent();
 418    }
 419
 420    /// <summary>
 421    /// Remove a media path.
 422    /// </summary>
 423    /// <param name="name">The name of the library.</param>
 424    /// <param name="path">The path to remove.</param>
 425    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 426    /// <returns>A <see cref="NoContentResult"/>.</returns>
 427    /// <response code="204">Media path removed.</response>
 428    /// <exception cref="ArgumentException">The name of the library and path may not be empty.</exception>
 429    [HttpDelete("Paths")]
 430    [ProducesResponseType(StatusCodes.Status204NoContent)]
 431    public ActionResult RemoveMediaPath(
 432        [FromQuery] string name,
 433        [FromQuery] string path,
 434        [FromQuery] bool refreshLibrary = false)
 435    {
 1436        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 1437        ArgumentException.ThrowIfNullOrWhiteSpace(path);
 438
 1439        _libraryMonitor.Stop();
 440
 441        try
 442        {
 1443            _libraryManager.RemoveMediaPath(name, path);
 0444        }
 445        finally
 446        {
 1447            Task.Run(async () =>
 1448            {
 1449                // No need to start if scanning the library because it will handle it
 1450                if (refreshLibrary)
 1451                {
 1452                    _libraryManager.QueueLibraryScan();
 1453                }
 1454                else
 1455                {
 1456                    // Need to add a delay here or directory watchers may still pick up the changes
 1457                    // Have to block here to allow exceptions to bubble
 1458                    await Task.Delay(1000).ConfigureAwait(false);
 1459                    _libraryMonitor.Start();
 1460                }
 1461            });
 1462        }
 463
 0464        return NoContent();
 465    }
 466
 467    /// <summary>
 468    /// Update library options.
 469    /// </summary>
 470    /// <param name="request">The library name and options.</param>
 471    /// <response code="204">Library updated.</response>
 472    /// <response code="404">Item not found.</response>
 473    /// <returns>A <see cref="NoContentResult"/>.</returns>
 474    [HttpPost("LibraryOptions")]
 475    [ProducesResponseType(StatusCodes.Status204NoContent)]
 476    [ProducesResponseType(StatusCodes.Status404NotFound)]
 477    public ActionResult UpdateLibraryOptions(
 478        [FromBody] UpdateLibraryOptionsDto request)
 479    {
 2480        var item = _libraryManager.GetItemById<CollectionFolder>(request.Id);
 2481        if (item is null)
 482        {
 1483            return NotFound();
 484        }
 485
 1486        LibraryOptions options = item.GetLibraryOptions();
 2487        foreach (var mediaPath in request.LibraryOptions!.PathInfos)
 488        {
 0489            if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
 490            {
 491                continue;
 492            }
 493
 0494            _libraryManager.CreateShortcut(item.Path, mediaPath);
 495        }
 496
 1497        item.UpdateLibraryOptions(request.LibraryOptions);
 1498        return NoContent();
 499    }
 500}