Bind the folder name an item-by-name entity resolves to

This commit is contained in:
Shadowghost
2026-08-25 20:19:52 +02:00
parent 422b2bb3d9
commit 8c0775e941
8 changed files with 99 additions and 24 deletions
@@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio
public static string GetPath(string name, bool normalizeName)
{
// Trim the period at the end because windows will have a hard time with that
var validName = normalizeName ?
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
name;
var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName);
}
@@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio
public static string GetPath(string name, bool normalizeName)
{
// Trim the period at the end because windows will have a hard time with that
var validName = normalizeName ?
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
name;
var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName);
}
@@ -48,6 +48,10 @@ namespace MediaBrowser.Controller.Entities
public const string ThemeSongFileName = "theme";
// Well below the 255 byte limit of the common Linux filesystems and the 255 character limit
// of Windows, so the files inside the folder still fit within MAX_PATH.
private const int MaxItemByNameFolderNameBytes = 128;
/// <summary>
/// The supported image extensions.
/// </summary>
@@ -941,6 +945,43 @@ namespace MediaBrowser.Controller.Entities
return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
/// <summary>
/// Turns an item-by-name entity's name into a folder name every supported filesystem accepts.
/// </summary>
/// <param name="name">The entity's name.</param>
/// <returns>The folder name.</returns>
public static string GetItemByNameFolderName(string name)
{
// Trim the period at the end because windows will have a hard time with that
var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.');
// Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be
// turned into a folder at all - and an entity with no folder can never be created, which
// leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan.
// Only broken provider data gets this long, but it still has to resolve to something, so
// keep a readable prefix and let a hash of the whole name tell two of them apart.
if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes)
{
return validName;
}
var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
var budget = MaxItemByNameFolderNameBytes - suffix.Length;
var length = Math.Min(validName.Length, budget);
while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget)
{
length--;
}
// Never cut a surrogate pair in half, the lone half is not a valid file name character.
if (length > 0 && char.IsHighSurrogate(validName[length - 1]))
{
length--;
}
return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix);
}
/// <summary>
/// Cleans a raw name into its sortable form by applying the configured sort rules.
/// </summary>
+1 -4
View File
@@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
// Trim the period at the end because windows will have a hard time with that
var validName = normalizeName ?
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
name;
var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName);
}
+1 -4
View File
@@ -98,10 +98,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
// Trim the period at the end because windows will have a hard time with that
var validFilename = normalizeName ?
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
name;
var validFilename = normalizeName ? GetItemByNameFolderName(name) : name;
string subFolderPrefix = null;
+1 -4
View File
@@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
// Trim the period at the end because windows will have a hard time with that
var validName = normalizeName ?
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
name;
var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName);
}
+1 -4
View File
@@ -85,10 +85,7 @@ namespace MediaBrowser.Controller.Entities
public static string GetPath(string name, bool normalizeName)
{
// Trim the period at the end because windows will have a hard time with that
var validName = normalizeName ?
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
name;
var validName = normalizeName ? GetItemByNameFolderName(name) : name;
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.YearPath, validName);
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
@@ -27,6 +28,57 @@ namespace Jellyfin.Controller.Tests.Entities;
public class BaseItemTests
{
[Fact]
public void GetItemByNameFolderName_ShortName_IsKeptAsIs()
{
SetupPassThroughFileSystem();
Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott."));
}
[Fact]
public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent()
{
SetupPassThroughFileSystem();
// What a provider result that concatenated a whole credit list into one name looks like.
var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20));
var folderName = BaseItem.GetItemByNameFolderName(name);
Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128);
Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal);
}
[Fact]
public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart()
{
SetupPassThroughFileSystem();
var prefix = new string('a', 200);
Assert.NotEqual(
BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"),
BaseItem.GetItemByNameFolderName(prefix + "Bob Kane"));
}
[Fact]
public void GetItemByNameFolderName_OverlongName_IsStable()
{
SetupPassThroughFileSystem();
var name = new string('a', 300);
Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name));
}
private static void SetupPassThroughFileSystem()
{
var fileSystem = new Mock<IFileSystem>();
fileSystem.Setup(x => x.GetValidFilename(It.IsAny<string>())).Returns((string name) => name);
BaseItem.FileSystem = fileSystem.Object;
}
[Theory]
[InlineData("", "")]
[InlineData("1", "0000000001")]