Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -405,15 +405,27 @@ private static void ApplyEmbeddedTags(PathParsedMetadata target, PathParsedMetad

/// <summary>
/// Extracts a normalized title stem from a filename for grouping purposes.
/// Strips leading track numbers, trailing Part/CD/Disc numbers, year and
/// series decorations in brackets. Files that resolve to the same stem are
/// treated as parts of the same audiobook. Returns the folder name as fallback
/// when the stem would otherwise be empty (for example purely numeric filenames).
/// Strips a trailing "N of M" index, leading track numbers, trailing
/// Part/CD/Disc numbers, year and series decorations in brackets. Files that
/// resolve to the same stem are treated as parts of the same audiobook. Returns
/// the folder name as fallback when the stem would otherwise be empty (for
/// example purely numeric filenames).
/// </summary>
private static string ExtractTitleStem(string filePath, string folderPath)
{
var name = Path.GetFileNameWithoutExtension(filePath);

// Strip a trailing "N of M" index: "Title 001 of 498", "Title - 3 of 12".
//
// This runs BEFORE the leading-track strip on purpose. A file named "001 of 498"
// with no title in it would otherwise lose its leading "001 " first, leaving
// "of 498" — a different stem in every file, and no longer numeric, so the
// folder-name fallback at the end never gets the chance to group them.
//
// Unlike a bare "(N)", this form is not ambiguous with a series marker: it
// carries its own total, so it says the file is one part of a set rather than
// one entry among separate works. Nothing is titled "Book 1 of 12".
name = Regex.Replace(name, @"[\s\-_]*\d+\s*of\s*\d+$", "", RegexOptions.IgnoreCase);
// Strip leading track/disc number prefix: "01 - ", "Track 01 - ", "1. "
name = Regex.Replace(name, @"^(track\s*)?\d+[\s\-_\.]+", "", RegexOptions.IgnoreCase);
// Strip trailing Part/CD/Disc/Chapter number: "- Part 1", "CD2", "Disc 2", "pt00"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,63 @@ public void BuildGroupedFilesForFolder_UsesAuthorToKeepSameTitleSeparated()
Assert.Contains(groups, group => group.Single() == fileA);
Assert.Contains(groups, group => group.Single() == fileB);
}

[Fact]
public void BuildGroupedFilesForFolder_GroupsChapterFilesIndexedAsNumberOfTotal()
{
// A chapter-per-file rip that numbers its parts "N of M". The index carries its
// own total, so it describes a set rather than distinct works, and every file
// belongs to the one book the folder names.
var folder = @"D:\test\Jack of Shadows";
var files = Enumerable.Range(1, 4)
.Select(n => Path.Join(folder, $"Jack of Shadows {n:000} of 004.mp3"))
.ToArray();

var groups = UnmatchedScanBackgroundService.BuildGroupedFilesForFolder(
files,
folder,
FileSystemPathSemantics.CurrentHostDefault);

var group = Assert.Single(groups);
Assert.Equal(4, group.Count);
}

[Fact]
public void BuildGroupedFilesForFolder_GroupsBareNumberOfTotalFilenames()
{
// The same convention with no title in the filename at all. Stripping the index
// empties the stem, which is what lets the folder-name fallback gather them.
var folder = @"D:\test\Jack of Shadows";
var files = Enumerable.Range(1, 3)
.Select(n => Path.Join(folder, $"{n:000} of 003.mp3"))
.ToArray();

var groups = UnmatchedScanBackgroundService.BuildGroupedFilesForFolder(
files,
folder,
FileSystemPathSemantics.CurrentHostDefault);

var group = Assert.Single(groups);
Assert.Equal(3, group.Count);
}

[Fact]
public void BuildGroupedFilesForFolder_KeepsTitlesWhoseOwnWordsReadLikeAnIndex()
{
// "of" between two words is not an index, and a trailing number is not a total.
// Two separate works in one author folder must stay separate.
var folder = @"D:\test\Roger Zelazny";
var jack = Path.Join(folder, "Jack of Shadows.mp3");
var nine = Path.Join(folder, "Nine Princes in Amber 2.mp3");

var groups = UnmatchedScanBackgroundService.BuildGroupedFilesForFolder(
new[] { jack, nine },
folder,
FileSystemPathSemantics.CurrentHostDefault);

Assert.Equal(2, groups.Count);
Assert.Contains(groups, group => group.Single() == jack);
Assert.Contains(groups, group => group.Single() == nine);
}
}
}