Fix version names

This commit is contained in:
Shadowghost
2026-06-02 10:13:31 +02:00
parent 53eda14dcc
commit 82b946733f
2 changed files with 172 additions and 7 deletions

View File

@@ -87,6 +87,10 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
// Separators the naming layer treats as version delimiters (Emby.Naming VideoFlagDelimiters),
// used when stripping the shared prefix from an alternate version's media source name.
private static readonly char[] VersionSeparators = [' ', '-', '_', '.'];
private string _sortName;
private string _forcedSortName;
@@ -1099,8 +1103,9 @@ namespace MediaBrowser.Controller.Entities
}
}
var list = GetAllItemsForMediaSources();
var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
var list = GetAllItemsForMediaSources().ToList();
var commonPrefix = GetCommonNamePrefix(list);
var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1128,7 +1133,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1146,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
Name = GetMediaSourceName(item),
Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1225,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
internal string GetMediaSourceName(BaseItem item)
internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List<string>();
@@ -1228,12 +1233,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
if (HasLocalAlternateVersions)
// Prefer the suffix that differs from the other versions: strip the prefix shared by
// all sibling files. This works regardless of folder layout, so it also labels episode
// versions that share a season folder (e.g. "Greyscale" instead of the full
// "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a separator
// boundary (see GetCommonVersionPrefix).
if (!string.IsNullOrEmpty(commonPrefix)
&& displayName.Length > commonPrefix.Length
&& displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
{
var name = displayName.AsSpan(commonPrefix.Length).TrimStart(VersionSeparators);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
}
}
// Fall back to the containing folder name (the common layout for movie versions, and
// the path taken when no common prefix could be derived).
if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
var name = displayName.AsSpan(containingFolderName.Length).TrimStart(VersionSeparators);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1314,85 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
/// <summary>
/// Derives the prefix shared by the supplied media source items' file names, used to strip the
/// common part and surface a short version label per source. Returns null when there are fewer
/// than two file-based sources, since there is nothing to differentiate.
/// </summary>
/// <param name="items">The media source items.</param>
/// <returns>The shared prefix, or null when no useful prefix exists.</returns>
private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
{
var fileNames = new List<string>();
foreach (var (item, _) in items)
{
if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
{
fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
}
}
if (fileNames.Count < 2)
{
return null;
}
var prefix = GetCommonVersionPrefix(fileNames);
return string.IsNullOrEmpty(prefix) ? null : prefix;
}
/// <summary>
/// Computes the case-insensitive longest common prefix of the supplied version file names,
/// retreated to the last separator boundary. Retreating keeps the differing suffix intact:
/// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
/// "Greyish") while still trimming the common part when every version is suffixed (e.g.
/// "- Greyscale" / "- Colorized"). The separators mirror the version delimiters recognised by
/// the naming layer (Emby.Naming VideoFlagDelimiters).
/// </summary>
/// <param name="fileNames">The version file names without extension; must contain at least one entry.</param>
/// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns>
internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames)
{
var prefix = fileNames[0];
for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
{
var name = fileNames[i];
var length = Math.Min(prefix.Length, name.Length);
var common = 0;
while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
{
common++;
}
prefix = prefix[..common];
}
// If the common prefix is itself a whole file name then one version is unlabelled (the
// base name); the boundary already sits at the end of that name, so don't retreat into it.
var prefixIsWholeName = false;
for (var i = 0; i < fileNames.Count; i++)
{
if (fileNames[i].Length == prefix.Length)
{
prefixIsWholeName = true;
break;
}
}
if (!prefixIsWholeName)
{
var cut = prefix.Length;
while (cut > 0 && Array.IndexOf(VersionSeparators, prefix[cut - 1]) < 0)
{
cut--;
}
prefix = prefix[..cut];
}
return prefix;
}
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);

View File

@@ -46,4 +46,66 @@ public class BaseItemTests
Assert.Equal(name, video.GetMediaSourceName(video));
Assert.Equal(altName, video.GetMediaSourceName(videoAlt));
}
[Theory]
// Episode versions share a season folder; the common prefix (not the folder name) yields the label.
// Both files carry a suffix (no bare base name), so the shared "- " must be stripped too.
[InlineData(
"Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
"Spider-Noir - S01E02 - Wo ist Flint - Colorized",
"Greyscale",
"Colorized")]
// One version is the bare base name; the other is suffixed.
[InlineData(
"Spider-Noir - S01E02 - Wo ist Flint",
"Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
"Spider-Noir - S01E02 - Wo ist Flint",
"Greyscale")]
// Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it.
[InlineData(
"Demo - S01E01 - Greyscale",
"Demo - S01E01 - Greyish",
"Greyscale",
"Greyish")]
// Underscore separator.
[InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")]
// Dot separator.
[InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")]
// Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i".
[InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")]
// Bracketed version labels: the opening bracket is kept in the label.
[InlineData(
"Blade Runner (1982) [Final Cut] [1080p HEVC AAC]",
"Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
"[Final Cut] [1080p HEVC AAC]",
"[EE by ADM] [480p HEVC AAC]")]
public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
{
var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv";
var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]);
var video = new Video()
{
Path = primaryPath
};
var videoAlt = new Video()
{
Path = altPath,
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>()))
.Returns((string x) => MediaProtocol.File);
var libraryManager = new Mock<ILibraryManager>();
// No local alternate versions: these are linked (separate items), so the folder fallback is unavailable.
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()))
.Returns(Array.Empty<Guid>());
BaseItem.MediaSourceManager = mediaSourceManager.Object;
BaseItem.LibraryManager = libraryManager.Object;
Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
}
}