< Summary - Jellyfin

Information
Class: Jellyfin.Api.Controllers.LibraryStructureController
Assembly: Jellyfin.Api
File(s): /srv/git/jellyfin/Jellyfin.Api/Controllers/LibraryStructureController.cs
Line coverage
55%
Covered lines: 72
Uncovered lines: 58
Coverable lines: 130
Total lines: 365
Line coverage: 55.3%
Branch coverage
50%
Covered branches: 16
Total branches: 32
Branch coverage: 50%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 AM Line coverage: 54.7% (64/117) Branch coverage: 45.8% (11/24) Total lines: 3594/16/2026 - 12:15:18 AM Line coverage: 54.7% (64/117) Branch coverage: 45.8% (11/24) Total lines: 3614/19/2026 - 12:14:27 AM Line coverage: 57.1% (72/126) Branch coverage: 50% (16/32) Total lines: 3615/5/2026 - 12:15:44 AM Line coverage: 55.3% (72/130) Branch coverage: 50% (16/32) Total lines: 365 1/23/2026 - 12:11:06 AM Line coverage: 54.7% (64/117) Branch coverage: 45.8% (11/24) Total lines: 3594/16/2026 - 12:15:18 AM Line coverage: 54.7% (64/117) Branch coverage: 45.8% (11/24) Total lines: 3614/19/2026 - 12:14:27 AM Line coverage: 57.1% (72/126) Branch coverage: 50% (16/32) Total lines: 3615/5/2026 - 12:15:44 AM Line coverage: 55.3% (72/130) Branch coverage: 50% (16/32) Total lines: 365

Coverage delta

Coverage delta 5 -5

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetVirtualFolders()100%11100%
AddVirtualFolder()62.5%8883.33%
RemoveVirtualFolder()100%11100%
RenameVirtualFolder(...)41.66%971216.07%
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 MediaBrowser.Common.Api;
 14using MediaBrowser.Controller;
 15using MediaBrowser.Controller.Configuration;
 16using MediaBrowser.Controller.Entities;
 17using MediaBrowser.Controller.Library;
 18using MediaBrowser.Model.Configuration;
 19using MediaBrowser.Model.Entities;
 20using Microsoft.AspNetCore.Authorization;
 21using Microsoft.AspNetCore.Http;
 22using Microsoft.AspNetCore.Mvc;
 23
 24namespace Jellyfin.Api.Controllers;
 25
 26/// <summary>
 27/// The library structure controller.
 28/// </summary>
 29[Route("Library/VirtualFolders")]
 30[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
 31public class LibraryStructureController : BaseJellyfinApiController
 32{
 33    private readonly IServerApplicationPaths _appPaths;
 34    private readonly ILibraryManager _libraryManager;
 35    private readonly ILibraryMonitor _libraryMonitor;
 36
 37    /// <summary>
 38    /// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
 39    /// </summary>
 40    /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param
 41    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
 42    /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
 1443    public LibraryStructureController(
 1444        IServerConfigurationManager serverConfigurationManager,
 1445        ILibraryManager libraryManager,
 1446        ILibraryMonitor libraryMonitor)
 47    {
 1448        _appPaths = serverConfigurationManager.ApplicationPaths;
 1449        _libraryManager = libraryManager;
 1450        _libraryMonitor = libraryMonitor;
 1451    }
 52
 53    /// <summary>
 54    /// Gets all virtual folders.
 55    /// </summary>
 56    /// <response code="200">Virtual folders retrieved.</response>
 57    /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns>
 58    [HttpGet]
 59    [ProducesResponseType(StatusCodes.Status200OK)]
 60    public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders()
 61    {
 162        return _libraryManager.GetVirtualFolders(true);
 63    }
 64
 65    /// <summary>
 66    /// Adds a virtual folder.
 67    /// </summary>
 68    /// <param name="name">The name of the virtual folder.</param>
 69    /// <param name="collectionType">The type of the collection.</param>
 70    /// <param name="paths">The paths of the virtual folder.</param>
 71    /// <param name="libraryOptionsDto">The library options.</param>
 72    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 73    /// <response code="204">Folder added.</response>
 74    /// <returns>A <see cref="NoContentResult"/>.</returns>
 75    [HttpPost]
 76    [ProducesResponseType(StatusCodes.Status204NoContent)]
 77    public async Task<ActionResult> AddVirtualFolder(
 78        [FromQuery]
 79        [RegularExpression(@"^(?:\S(?:.*\S)?)$", ErrorMessage = "Library name cannot be empty or have leading/trailing s
 80        string name,
 81        [FromQuery] CollectionTypeOptions? collectionType,
 82        [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] paths,
 83        [FromBody] AddVirtualFolderDto? libraryOptionsDto,
 84        [FromQuery] bool refreshLibrary = false)
 85    {
 286        var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
 87
 288        if (paths is not null && paths.Length > 0)
 89        {
 090            libraryOptions.PathInfos = Array.ConvertAll(paths, i => new MediaPathInfo(i));
 91        }
 92
 293        await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(fals
 94
 295        return NoContent();
 296    }
 97
 98    /// <summary>
 99    /// Removes a virtual folder.
 100    /// </summary>
 101    /// <param name="name">The name of the folder.</param>
 102    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 103    /// <response code="204">Folder removed.</response>
 104    /// <response code="404">Folder not found.</response>
 105    /// <returns>A <see cref="NoContentResult"/>.</returns>
 106    [HttpDelete]
 107    [ProducesResponseType(StatusCodes.Status204NoContent)]
 108    public async Task<ActionResult> RemoveVirtualFolder(
 109        [FromQuery] string name,
 110        [FromQuery] bool refreshLibrary = false)
 111    {
 112        // TODO: refactor! this relies on an FileNotFound exception to return NotFound when attempting to remove a libra
 2113        await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
 114
 1115        return NoContent();
 1116    }
 117
 118    /// <summary>
 119    /// Renames a virtual folder.
 120    /// </summary>
 121    /// <param name="name">The name of the virtual folder.</param>
 122    /// <param name="newName">The new name.</param>
 123    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 124    /// <response code="204">Folder renamed.</response>
 125    /// <response code="404">Library doesn't exist.</response>
 126    /// <response code="409">Library already exists.</response>
 127    /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist
 128    /// <exception cref="ArgumentNullException">The new name may not be null.</exception>
 129    [HttpPost("Name")]
 130    [ProducesResponseType(StatusCodes.Status204NoContent)]
 131    [ProducesResponseType(StatusCodes.Status404NotFound)]
 132    [ProducesResponseType(StatusCodes.Status409Conflict)]
 133    public ActionResult RenameVirtualFolder(
 134        [FromQuery] string? name,
 135        [FromQuery] string? newName,
 136        [FromQuery] bool refreshLibrary = false)
 137    {
 3138        if (string.IsNullOrWhiteSpace(name))
 139        {
 1140            throw new ArgumentNullException(nameof(name));
 141        }
 142
 2143        if (string.IsNullOrWhiteSpace(newName))
 144        {
 1145            throw new ArgumentNullException(nameof(newName));
 146        }
 147
 1148        var rootFolderPath = _appPaths.DefaultUserViewsPath;
 149
 1150        var currentPath = Path.Combine(rootFolderPath, name);
 1151        var newPath = Path.Combine(rootFolderPath, newName);
 152
 1153        if (!Directory.Exists(currentPath))
 154        {
 1155            return NotFound("The media collection does not exist.");
 156        }
 157
 0158        if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
 159        {
 0160            return Conflict($"The media library already exists at {newPath}.");
 161        }
 162
 0163        _libraryMonitor.Stop();
 164
 165        try
 166        {
 167            // Changing capitalization. Handle windows case insensitivity
 0168            if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
 169            {
 0170                var tempPath = Path.Combine(
 0171                    rootFolderPath,
 0172                    Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
 0173                Directory.Move(currentPath, tempPath);
 0174                currentPath = tempPath;
 175            }
 176
 0177            Directory.Move(currentPath, newPath);
 0178        }
 179        finally
 180        {
 0181            CollectionFolder.OnCollectionFolderChange();
 182
 0183            Task.Run(async () =>
 0184            {
 0185                // No need to start if scanning the library because it will handle it
 0186                if (refreshLibrary)
 0187                {
 0188                    await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
 0189                    var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath,
 0190                    if (newLib is CollectionFolder folder)
 0191                    {
 0192                        _libraryManager.ClearIgnoreRuleCache();
 0193                        foreach (var child in folder.GetPhysicalFolders())
 0194                        {
 0195                            await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
 0196                            await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(
 0197                        }
 0198                    }
 0199                    else
 0200                    {
 0201                        _libraryManager.ClearIgnoreRuleCache();
 0202                        // We don't know if this one can be validated individually, trigger a new validation
 0203                        await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).Confi
 0204                    }
 0205
 0206                    _libraryManager.ClearIgnoreRuleCache();
 0207                }
 0208                else
 0209                {
 0210                    // Need to add a delay here or directory watchers may still pick up the changes
 0211                    // Have to block here to allow exceptions to bubble
 0212                    await Task.Delay(1000).ConfigureAwait(false);
 0213                    _libraryMonitor.Start();
 0214                }
 0215            });
 0216        }
 217
 0218        return NoContent();
 219    }
 220
 221    /// <summary>
 222    /// Add a media path to a library.
 223    /// </summary>
 224    /// <param name="mediaPathDto">The media path dto.</param>
 225    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 226    /// <returns>A <see cref="NoContentResult"/>.</returns>
 227    /// <response code="204">Media path added.</response>
 228    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
 229    [HttpPost("Paths")]
 230    [ProducesResponseType(StatusCodes.Status204NoContent)]
 231    public ActionResult AddMediaPath(
 232        [FromBody, Required] MediaPathDto mediaPathDto,
 233        [FromQuery] bool refreshLibrary = false)
 234    {
 1235        _libraryMonitor.Stop();
 236
 237        try
 238        {
 1239            var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException(
 240
 1241            _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath);
 0242        }
 243        finally
 244        {
 1245            Task.Run(async () =>
 1246            {
 1247                // No need to start if scanning the library because it will handle it
 1248                if (refreshLibrary)
 1249                {
 1250                    await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).Configure
 1251                }
 1252                else
 1253                {
 1254                    // Need to add a delay here or directory watchers may still pick up the changes
 1255                    // Have to block here to allow exceptions to bubble
 1256                    await Task.Delay(1000).ConfigureAwait(false);
 1257                    _libraryMonitor.Start();
 1258                }
 1259            });
 1260        }
 261
 0262        return NoContent();
 263    }
 264
 265    /// <summary>
 266    /// Updates a media path.
 267    /// </summary>
 268    /// <param name="mediaPathRequestDto">The name of the library and path infos.</param>
 269    /// <returns>A <see cref="NoContentResult"/>.</returns>
 270    /// <response code="204">Media path updated.</response>
 271    /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
 272    [HttpPost("Paths/Update")]
 273    [ProducesResponseType(StatusCodes.Status204NoContent)]
 274    public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto)
 275    {
 0276        if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name))
 277        {
 0278            throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty");
 279        }
 280
 0281        _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo);
 0282        return NoContent();
 283    }
 284
 285    /// <summary>
 286    /// Remove a media path.
 287    /// </summary>
 288    /// <param name="name">The name of the library.</param>
 289    /// <param name="path">The path to remove.</param>
 290    /// <param name="refreshLibrary">Whether to refresh the library.</param>
 291    /// <returns>A <see cref="NoContentResult"/>.</returns>
 292    /// <response code="204">Media path removed.</response>
 293    /// <exception cref="ArgumentException">The name of the library and path may not be empty.</exception>
 294    [HttpDelete("Paths")]
 295    [ProducesResponseType(StatusCodes.Status204NoContent)]
 296    public ActionResult RemoveMediaPath(
 297        [FromQuery] string name,
 298        [FromQuery] string path,
 299        [FromQuery] bool refreshLibrary = false)
 300    {
 1301        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 1302        ArgumentException.ThrowIfNullOrWhiteSpace(path);
 303
 1304        _libraryMonitor.Stop();
 305
 306        try
 307        {
 1308            _libraryManager.RemoveMediaPath(name, path);
 0309        }
 310        finally
 311        {
 1312            Task.Run(async () =>
 1313            {
 1314                // No need to start if scanning the library because it will handle it
 1315                if (refreshLibrary)
 1316                {
 1317                    await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).Configure
 1318                }
 1319                else
 1320                {
 1321                    // Need to add a delay here or directory watchers may still pick up the changes
 1322                    // Have to block here to allow exceptions to bubble
 1323                    await Task.Delay(1000).ConfigureAwait(false);
 1324                    _libraryMonitor.Start();
 1325                }
 1326            });
 1327        }
 328
 0329        return NoContent();
 330    }
 331
 332    /// <summary>
 333    /// Update library options.
 334    /// </summary>
 335    /// <param name="request">The library name and options.</param>
 336    /// <response code="204">Library updated.</response>
 337    /// <response code="404">Item not found.</response>
 338    /// <returns>A <see cref="NoContentResult"/>.</returns>
 339    [HttpPost("LibraryOptions")]
 340    [ProducesResponseType(StatusCodes.Status204NoContent)]
 341    [ProducesResponseType(StatusCodes.Status404NotFound)]
 342    public ActionResult UpdateLibraryOptions(
 343        [FromBody] UpdateLibraryOptionsDto request)
 344    {
 2345        var item = _libraryManager.GetItemById<CollectionFolder>(request.Id);
 2346        if (item is null)
 347        {
 1348            return NotFound();
 349        }
 350
 1351        LibraryOptions options = item.GetLibraryOptions();
 2352        foreach (var mediaPath in request.LibraryOptions!.PathInfos)
 353        {
 0354            if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
 355            {
 356                continue;
 357            }
 358
 0359            _libraryManager.CreateShortcut(item.Path, mediaPath);
 360        }
 361
 1362        item.UpdateLibraryOptions(request.LibraryOptions);
 1363        return NoContent();
 364    }
 365}