From 300036c85913b79bf9bbf13c81ea2241f1216f78 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Mon, 23 Mar 2026 17:08:15 -0400 Subject: Fix FolderStorageInfo to show parent filesystem A direct implementation using DriveInfo directly on a path does not work as expected. The method will return a DriveInfo object with the given path as both the Name and the RootDirectory, which is not helpful. Instead, add parsing logic to find the best possible match out of all filesystems on the system for the path, including handling edge cases involving symlinked paths in the chain. This ensures that the resulting DeviceId is a valid filesystem, allowing it to be used in the UI to show a better description. It also includes the new ResolvedPath which will show, if required, what the Path resolved to after all symlinks are interpolated. One possible issue here is that walking all drives as-is might become slow(er) on a system with many partitions, but even on my partition-heavy system with over a dozen ZVOLs and remote mounts, this takes under 0.4 seconds including runup time for `dotnet run`, so I imagine this should be fine. --- .../StorageHelpers/StorageHelper.cs | 53 +++++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index ce628a04d0..0b8e2830d2 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -28,22 +28,41 @@ public static class StorageHelper } /// - /// Gets the free space of a specific directory. + /// Gets the free space of the parent filesystem of a specific directory. /// /// Path to a folder. - /// The number of bytes available space. + /// Various details about the parent filesystem containing the directory. public static FolderStorageInfo GetFreeSpaceOf(string path) { try { - var driveInfo = new DriveInfo(path); + // Fully resolve the given path to an actual filesystem target, in case it's a symlink or similar. + resolvedPath = ResolvePath(path); + // We iterate all filesystems reported by GetDrives() here, and attempt to find the best + // match that contains, as deep as possible, the given path. + // This is required because simply calling `DriveInfo` on a path returns that path as + // the Name and RootDevice, which is not at all how this should work. + DriveInfo[] allDrives = DriveInfo.GetDrives(); + DriveInfo bestMatch = null; + foreach (DriveInfo d in allDrives) + { + if (resolvedPath.StartsWith(d.RootDirectory.FullName) && + (bestMatch == null || d.RootDirectory.FullName.Length > bestMatch.RootDirectory.FullName.Length)) + { + bestMatch = d; + } + } + if (bestMatch is null) { + throw new InvalidOperationException($"The path `{path}` has no matching parent device. Space check invalid."); + } return new FolderStorageInfo() { Path = path, - FreeSpace = driveInfo.AvailableFreeSpace, - UsedSpace = driveInfo.TotalSize - driveInfo.AvailableFreeSpace, - StorageType = driveInfo.DriveType.ToString(), - DeviceId = driveInfo.Name, + ResolvedPath = resolvedPath, + FreeSpace = bestMatch.AvailableFreeSpace, + UsedSpace = bestMatch.TotalSize - bestMatch.AvailableFreeSpace, + StorageType = bestMatch.DriveType.ToString(), + DeviceId = bestMatch.Name, }; } catch @@ -59,6 +78,26 @@ public static class StorageHelper } } + /// + /// Walk a path and fully resolve any symlinks within it. + /// + private static string ResolvePath(string path) + { + var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = Path.DirectorySeparatorChar.ToString(); + foreach (var part in parts) + { + current = Path.Combine(current, part); + var resolved = new DirectoryInfo(current).ResolveLinkTarget(returnFinalTarget: true); + if (resolved is not null) + { + current = resolved.FullName; + } + } + + return current; + } + /// /// Gets the underlying drive data from a given path and checks if the available storage capacity matches the threshold. /// -- cgit v1.2.3 From 434ebc8b110a2736c9be08360c17cf74e27803d1 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Mon, 23 Mar 2026 17:11:29 -0400 Subject: Ensure ResolvedPath is sent on error too --- Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs | 1 + 1 file changed, 1 insertion(+) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index 0b8e2830d2..a36a51330d 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -70,6 +70,7 @@ public static class StorageHelper return new FolderStorageInfo() { Path = path, + ResolvedPath = path, FreeSpace = -1, UsedSpace = -1, StorageType = null, -- cgit v1.2.3 From 8142bbd50e4c2218e99c621900430b0189c267c3 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Mon, 23 Mar 2026 17:22:35 -0400 Subject: Properly define variable type --- Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index a36a51330d..b80d65ecbe 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -37,7 +37,7 @@ public static class StorageHelper try { // Fully resolve the given path to an actual filesystem target, in case it's a symlink or similar. - resolvedPath = ResolvePath(path); + string resolvedPath = ResolvePath(path); // We iterate all filesystems reported by GetDrives() here, and attempt to find the best // match that contains, as deep as possible, the given path. // This is required because simply calling `DriveInfo` on a path returns that path as -- cgit v1.2.3 From 965b602c6890623130d1d7e27de52e161c6d1069 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Mon, 23 Mar 2026 23:09:56 -0400 Subject: Apply suggestions from code review Co-authored-by: JPVenson --- .../StorageHelpers/StorageHelper.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index b80d65ecbe..d3f94ad0bd 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -37,24 +37,26 @@ public static class StorageHelper try { // Fully resolve the given path to an actual filesystem target, in case it's a symlink or similar. - string resolvedPath = ResolvePath(path); + var resolvedPath = ResolvePath(path); // We iterate all filesystems reported by GetDrives() here, and attempt to find the best // match that contains, as deep as possible, the given path. // This is required because simply calling `DriveInfo` on a path returns that path as // the Name and RootDevice, which is not at all how this should work. - DriveInfo[] allDrives = DriveInfo.GetDrives(); - DriveInfo bestMatch = null; + var allDrives = DriveInfo.GetDrives(); + DriveInfo? bestMatch = null; foreach (DriveInfo d in allDrives) { - if (resolvedPath.StartsWith(d.RootDirectory.FullName) && - (bestMatch == null || d.RootDirectory.FullName.Length > bestMatch.RootDirectory.FullName.Length)) + if (resolvedPath.StartsWith(d.RootDirectory.FullName, StringComparison.InvariantCultureIgnoreCase) && + (bestMatch is null || d.RootDirectory.FullName.Length > bestMatch.RootDirectory.FullName.Length)) { bestMatch = d; } } + if (bestMatch is null) { throw new InvalidOperationException($"The path `{path}` has no matching parent device. Space check invalid."); } + return new FolderStorageInfo() { Path = path, -- cgit v1.2.3 From fec78c8448bd19f96460d853732cf24812443b70 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Tue, 24 Mar 2026 22:31:17 -0400 Subject: Lint for the Linter Gods --- Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index d3f94ad0bd..13c7895f83 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -43,7 +43,7 @@ public static class StorageHelper // This is required because simply calling `DriveInfo` on a path returns that path as // the Name and RootDevice, which is not at all how this should work. var allDrives = DriveInfo.GetDrives(); - DriveInfo? bestMatch = null; + DriveInfo? bestMatch = null; foreach (DriveInfo d in allDrives) { if (resolvedPath.StartsWith(d.RootDirectory.FullName, StringComparison.InvariantCultureIgnoreCase) && @@ -52,11 +52,12 @@ public static class StorageHelper bestMatch = d; } } - - if (bestMatch is null) { + + if (bestMatch is null) + { throw new InvalidOperationException($"The path `{path}` has no matching parent device. Space check invalid."); } - + return new FolderStorageInfo() { Path = path, -- cgit v1.2.3 From 22644075e784ccd71d39a27eae6d1f7434f47a00 Mon Sep 17 00:00:00 2001 From: dkanada Date: Sun, 12 Apr 2026 12:42:49 +0900 Subject: add NameStartsWith and NameLessThan filters to Person search --- Jellyfin.Api/Controllers/PersonsController.cs | 6 ++++++ Jellyfin.Server.Implementations/Item/PeopleRepository.cs | 10 ++++++++++ MediaBrowser.Controller/Entities/InternalPeopleQuery.cs | 4 ++++ 3 files changed, 20 insertions(+) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 2b2afb0fe6..a113ded049 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -50,6 +50,8 @@ public class PersonsController : BaseJellyfinApiController /// Optional. All items with a lower index will be dropped from the response. /// Optional. The maximum number of records to return. /// The search term. + /// Optional. Filter by items whose name starts with the given input string. + /// Optional. Filter by items whose name will appear before this value when sorted alphabetically. /// Optional. Specify additional fields of information to return in the output. /// Optional. Specify additional filters to apply. /// Optional filter by items that are marked as favorite, or not. userId is required. @@ -70,6 +72,8 @@ public class PersonsController : BaseJellyfinApiController [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, @@ -97,6 +101,8 @@ public class PersonsController : BaseJellyfinApiController excludePersonTypes) { NameContains = searchTerm, + NameStartsWith = nameStartsWith, + NameLessThan = nameLessThan, User = user, IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, AppearsInItemId = appearsInItemId ?? Guid.Empty, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index ad9953d1b6..adb5e08cf9 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -235,6 +235,16 @@ public class PeopleRepository(IDbContextFactory dbProvider, I query = query.Where(e => e.Name.ToUpper().Contains(nameContainsUpper)); } + if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) + { + query = query.Where(e => e.Name.StartsWith(filter.NameStartsWith.ToLowerInvariant())); + } + + if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) + { + query = query.Where(e => e.Name.CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0); + } + return query; } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index f4b3910b0e..373ec7ffeb 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -42,6 +42,10 @@ namespace MediaBrowser.Controller.Entities public string NameContains { get; set; } + public string NameStartsWith { get; set; } + + public string NameLessThan { get; set; } + public User User { get; set; } public bool? IsFavorite { get; set; } -- cgit v1.2.3 From bb265cd4030e966aec1afbd31eafc0973531c232 Mon Sep 17 00:00:00 2001 From: dkanada Date: Mon, 13 Apr 2026 13:50:04 +0900 Subject: add NameStartsWithOrGreater parameter to Persons endpoint --- Jellyfin.Api/Controllers/PersonsController.cs | 3 +++ Jellyfin.Server.Implementations/Item/PeopleRepository.cs | 5 +++++ MediaBrowser.Controller/Entities/InternalPeopleQuery.cs | 2 ++ 3 files changed, 10 insertions(+) (limited to 'Jellyfin.Server.Implementations') diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index a113ded049..1811a219ac 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -52,6 +52,7 @@ public class PersonsController : BaseJellyfinApiController /// The search term. /// Optional. Filter by items whose name starts with the given input string. /// Optional. Filter by items whose name will appear before this value when sorted alphabetically. + /// Optional. Filter by items whose name will appear after this value when sorted alphabetically. /// Optional. Specify additional fields of information to return in the output. /// Optional. Specify additional filters to apply. /// Optional filter by items that are marked as favorite, or not. userId is required. @@ -74,6 +75,7 @@ public class PersonsController : BaseJellyfinApiController [FromQuery] string? searchTerm, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, + [FromQuery] string? nameStartsWithOrGreater, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, @@ -103,6 +105,7 @@ public class PersonsController : BaseJellyfinApiController NameContains = searchTerm, NameStartsWith = nameStartsWith, NameLessThan = nameLessThan, + NameStartsWithOrGreater = nameStartsWithOrGreater, User = user, IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, AppearsInItemId = appearsInItemId ?? Guid.Empty, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index adb5e08cf9..7147fbfe7d 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -245,6 +245,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I query = query.Where(e => e.Name.CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0); } + if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) + { + query = query.Where(e => e.Name.CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0); + } + return query; } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 373ec7ffeb..e12ba22343 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -46,6 +46,8 @@ namespace MediaBrowser.Controller.Entities public string NameLessThan { get; set; } + public string NameStartsWithOrGreater { get; set; } + public User User { get; set; } public bool? IsFavorite { get; set; } -- cgit v1.2.3