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
82 changes: 16 additions & 66 deletions listenarr.api/Controllers/LibraryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
using Listenarr.Domain.Common;
using Listenarr.Application.Interfaces;
using Listenarr.Domain.Models.Configurations;
using Listenarr.Domain.Models.Naming;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Notification;
using Listenarr.Application.Security;
Expand Down Expand Up @@ -3479,64 +3480,29 @@ private string ComputeAudiobookBaseDirectoryFromPattern(Audiobook audiobook, str
directoryPattern = Regex.Replace(directoryPattern, @"^\s*[\\/]", "");
directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*$", "");

// If the pattern is now empty or doesn't contain directory separators, use a fallback
if (string.IsNullOrWhiteSpace(directoryPattern) || !directoryPattern.Contains("/"))
// If the pattern is now empty, use a fallback; deliberately flat (slash-less)
// patterns are applied exactly as configured. Matches the orchestrator's legacy default
// (FileNamingService.BuildPath/BuildDirectory); empty {Series} is collapsed away.
if (string.IsNullOrWhiteSpace(directoryPattern))
{
directoryPattern = "{Author}/{Title}";
directoryPattern = "{Author}/{Series}/{Title}";
}
}
else
{
// Fallback to default directory pattern
directoryPattern = "{Author}/{Title}";
// Fallback to default directory pattern (aligned with the orchestrator's legacy default).
directoryPattern = "{Author}/{Series}/{Title}";
}

// For series books, ensure we include the series in the directory structure
if (!string.IsNullOrWhiteSpace(audiobook.Series) && !directoryPattern.Contains("{Series}"))
{
// Insert series between author and title if not already present
if (directoryPattern.Contains("{Author}/{Title}"))
{
directoryPattern = directoryPattern.Replace("{Author}/{Title}", "{Author}/{Series}/{Title}");
}
else if (directoryPattern.Contains("{Author}/"))
{
directoryPattern = directoryPattern.Replace("{Author}/", "{Author}/{Series}/");
}
}
// An empty {Series} (no series metadata) is handled by ApplyNamingPattern below, which
// substitutes empty tokens and collapses the surrounding separators. We deliberately do
// not strip {Series} textually here: a regex like \{Series[^}]*\} also matches
// {SeriesNumber}/{SeriesNumber:00}, which would drop those tokens from patterns that use
// them. Applying the pattern exactly and letting the sentinel cleanup remove empties keeps
// {SeriesNumber} intact.

// If the audiobook has no Series, remove any {Series} tokens from the directory pattern
// Tests expect the controller to strip the Series token when series metadata is missing.
if (string.IsNullOrWhiteSpace(audiobook.Series))
{
directoryPattern = Regex.Replace(directoryPattern, @"\{Series[^}]*\}", string.Empty, RegexOptions.IgnoreCase);
// Clean up any resulting duplicate separators or empty parts again
directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*[\\/]", "/");
directoryPattern = Regex.Replace(directoryPattern, @"^\s*[\\/]", "");
directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*$", "");
}

// Build variables for naming pattern using audiobook-level metadata
var variables = new Dictionary<string, object>
{
{ "Author", SanitizeDirectoryName(audiobook.Authors?.FirstOrDefault() ?? "Unknown Author") },
{ "Series", SanitizeDirectoryName(!string.IsNullOrWhiteSpace(audiobook.Series) ? audiobook.Series! : string.Empty) },
{ "Title", SanitizeDirectoryName(audiobook.Title ?? "Unknown Title") },
{ "Subtitle", SanitizeDirectoryName(audiobook.Subtitle ?? string.Empty) },
{ "Edition", SanitizeDirectoryName(audiobook.Edition ?? string.Empty) },
{ "Narrator", SanitizeDirectoryName((audiobook.Narrators != null && audiobook.Narrators.Any()) ? string.Join(", ", audiobook.Narrators.Where(n => !string.IsNullOrWhiteSpace(n))) : string.Empty) },
{ "Publisher", SanitizeDirectoryName(audiobook.Publisher ?? string.Empty) },
{ "Language", SanitizeDirectoryName(audiobook.Language ?? string.Empty) },
{ "Asin", SanitizeDirectoryName(audiobook.Asin ?? string.Empty) },
{ "SeriesNumber", audiobook.SeriesNumber ?? string.Empty },
{ "Year", audiobook.PublishYear ?? string.Empty },
{ "Quality", string.Empty },
{ "DiskNumber", string.Empty },
{ "ChapterNumber", string.Empty }
};

// Apply the directory pattern to get the relative directory path
var relative = _fileNamingService.ApplyNamingPattern(directoryPattern, variables, false);
// Apply the directory pattern using the unified naming variables (single sanitizer + token engine).
var relative = _fileNamingService.ApplyNamingPattern(directoryPattern, NamingContext.From(audiobook), false);

// Combine with root path
var combined = ResolvePathWithOptionalBase(rootPath, relative);
Expand Down Expand Up @@ -3646,22 +3612,6 @@ private string GetCommonPath(List<string> paths)
return commonPath;
}

private string SanitizeDirectoryName(string name)
{
// Remove or replace characters that are invalid in directory names
var invalidChars = Path.GetInvalidFileNameChars();
foreach (var c in invalidChars)
{
name = name.Replace(c, '_');
}

// Also replace some additional characters that might cause issues
name = name.Replace(":", "_").Replace("*", "_").Replace("?", "_").Replace("\"", "_").Replace("<", "_").Replace(">", "_").Replace("|", "_");

// Trim whitespace and return
return name.Trim();
}

private static string ComputeShortHash(string? input)
{
if (string.IsNullOrEmpty(input))
Expand Down
132 changes: 14 additions & 118 deletions listenarr.api/Controllers/ManualImportController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using Listenarr.Api.Dtos.ManualImport;
using Listenarr.Domain.Models.Enumerations;
using Listenarr.Domain.Models.Configurations;
using Listenarr.Domain.Models.Naming;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Domain.Models;

Expand Down Expand Up @@ -408,9 +409,6 @@ private async Task PersistAudiobookBasePathAsync(Audiobook audiobook, string? ba
private async Task<string> GenerateManualImportPathAsync(Audiobook audiobook, AudioMetadata metadata, ManualImportItemDto item, List<RootFolder> rootFolders, ApplicationSettings settings, bool isMultiFile = false)
{
var sourceFilePath = item.FullPath ?? string.Empty;
// Get the configured folder/file naming patterns from settings
var folderPattern = settings.FolderNamingPattern;
var filePattern = isMultiFile ? settings.MultiFileNamingPattern : settings.FileNamingPattern;

// If a custom BasePath is set (different from configured OutputPath AND not a known
// root folder), store directly under that path using file-only naming.
Expand Down Expand Up @@ -457,56 +455,6 @@ private async Task<string> GenerateManualImportPathAsync(Audiobook audiobook, Au
extension = ".m4b"; // Fallback if no extension
}

// Build variables for the pattern - only include non-empty values
var variables = new Dictionary<string, object>();

// Get first author from Authors list
var author = audiobook.Authors?.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(author))
variables["Author"] = author;

var narrator = audiobook.Narrators != null
? string.Join(", ", audiobook.Narrators.Where(n => !string.IsNullOrWhiteSpace(n)))
: string.Empty;
if (!string.IsNullOrWhiteSpace(narrator))
variables["Narrator"] = narrator;

if (!string.IsNullOrWhiteSpace(audiobook.Publisher))
variables["Publisher"] = audiobook.Publisher;

if (!string.IsNullOrWhiteSpace(audiobook.Language))
variables["Language"] = audiobook.Language;

if (!string.IsNullOrWhiteSpace(audiobook.Asin))
variables["Asin"] = audiobook.Asin;

if (!string.IsNullOrWhiteSpace(audiobook.Subtitle))
variables["Subtitle"] = audiobook.Subtitle;

if (!string.IsNullOrWhiteSpace(audiobook.Edition))
variables["Edition"] = audiobook.Edition;

// Preserve the older title+subtitle uniqueness behavior unless the user explicitly uses {Subtitle}.
// (e.g. "The Land" + "Founding" → "The Land: Founding")
var usesSubtitleToken = (!string.IsNullOrWhiteSpace(folderPattern) && folderPattern.IndexOf("Subtitle", StringComparison.OrdinalIgnoreCase) >= 0)
|| (!string.IsNullOrWhiteSpace(filePattern) && filePattern.IndexOf("Subtitle", StringComparison.OrdinalIgnoreCase) >= 0);

var titleFull = !usesSubtitleToken
&& !string.IsNullOrWhiteSpace(audiobook.Subtitle)
&& !string.IsNullOrWhiteSpace(audiobook.Title)
&& !audiobook.Title.Contains(audiobook.Subtitle, StringComparison.OrdinalIgnoreCase)
? $"{audiobook.Title}: {audiobook.Subtitle}"
: audiobook.Title;
variables["Title"] = !string.IsNullOrWhiteSpace(titleFull)
? titleFull
: "Unknown Title"; // Title is required as fallback

if (!string.IsNullOrWhiteSpace(audiobook.Series))
variables["Series"] = audiobook.Series;

if (!string.IsNullOrWhiteSpace(audiobook.PublishYear))
variables["Year"] = audiobook.PublishYear;

var effectiveDiskNumber = item.DiskNumberHint
?? (metadata.DiscNumber.HasValue && metadata.DiscNumber.Value > 0 ? metadata.DiscNumber.Value : null);
var effectiveChapterNumber = item.ChapterNumberHint
Expand All @@ -518,76 +466,24 @@ private async Task<string> GenerateManualImportPathAsync(Audiobook audiobook, Au
effectiveChapterNumber ??= effectiveDiskNumber;
}

if (effectiveDiskNumber.HasValue && effectiveDiskNumber.Value > 0)
variables["DiskNumber"] = effectiveDiskNumber.Value;

if (effectiveChapterNumber.HasValue && effectiveChapterNumber.Value > 0)
variables["ChapterNumber"] = effectiveChapterNumber.Value;

var stableSuffixNumber = effectiveChapterNumber ?? effectiveDiskNumber ?? item.SequenceNumberHint;

string relativePath;
var patternHasNumberTokens = !string.IsNullOrWhiteSpace(filePattern)
&& (filePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0
|| filePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0);

if (string.IsNullOrWhiteSpace(folderPattern))
{
// Legacy behavior: use FileNamingPattern as the full relative path pattern
var legacyPattern = string.IsNullOrWhiteSpace(filePattern)
? "{Author}/{Title}/{Title}"
: filePattern;

relativePath = _fileNamingService.ApplyNamingPattern(legacyPattern, variables, treatAsFilename: false);
}
else if (isCustomBasePath)
{
// Custom base path: only apply file naming pattern, not folder pattern
// (the BasePath already represents the folder location)
var effectiveFilePattern = string.IsNullOrWhiteSpace(filePattern) ? "{Title}" : filePattern;

var patternAllowsSubfolders = effectiveFilePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0
|| effectiveFilePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0
|| effectiveFilePattern.IndexOf('/') >= 0
|| effectiveFilePattern.IndexOf('\\') >= 0;

relativePath = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, treatAsFilename: !patternAllowsSubfolders);
}
else
{
// New behavior: separate folder and file patterns
var effectiveFilePattern = string.IsNullOrWhiteSpace(filePattern) ? "{Title}" : filePattern;

var folderRelative = _fileNamingService.ApplyNamingPattern(folderPattern, variables, treatAsFilename: false);

var patternAllowsSubfolders = effectiveFilePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0
|| effectiveFilePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0
|| effectiveFilePattern.IndexOf('/') >= 0
|| effectiveFilePattern.IndexOf('\\') >= 0;

var fileRelative = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, treatAsFilename: !patternAllowsSubfolders);

if (isMultiFile && !patternHasNumberTokens && stableSuffixNumber.HasValue)
fileRelative = FileUtils.AppendSequenceSuffix(fileRelative, stableSuffixNumber.Value);

relativePath = string.IsNullOrWhiteSpace(folderRelative)
? fileRelative
: CombineWithOptionalBase(folderRelative, fileRelative);
}

if ((string.IsNullOrWhiteSpace(folderPattern) || isCustomBasePath)
&& isMultiFile
&& !patternHasNumberTokens
&& stableSuffixNumber.HasValue)
var context = NamingContext.From(audiobook) with
{
relativePath = FileUtils.AppendSequenceSuffix(relativePath, stableSuffixNumber.Value);
}
DiskNumber = effectiveDiskNumber.HasValue && effectiveDiskNumber.Value > 0 ? effectiveDiskNumber : null,
ChapterNumber = effectiveChapterNumber.HasValue && effectiveChapterNumber.Value > 0 ? effectiveChapterNumber : null,
};

// Ensure it has the correct extension
if (!relativePath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
// OutputRoot is empty so the relative path is combined with the resolved basePath below.
var result = _fileNamingService.BuildPath(context, settings, new NamingOptions
{
relativePath += extension;
}
OutputRoot = string.Empty,
IsCustomBasePath = isCustomBasePath,
IsMultiFile = isMultiFile,
SequenceNumber = stableSuffixNumber,
Extension = extension,
});
var relativePath = result.RelativePath;

return string.IsNullOrWhiteSpace(basePath)
? relativePath
Expand Down
3 changes: 2 additions & 1 deletion listenarr.application/Audiobooks/LibraryAddService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Metadata;
using Listenarr.Domain.Models;
using Listenarr.Domain.Models.Naming;
using Microsoft.Extensions.Logging;

namespace Listenarr.Application.Audiobooks
Expand Down Expand Up @@ -172,7 +173,7 @@ public async Task<LibraryAddOperationResult> AddToLibraryAsync(
var rootFolder = await _rootFolderService.GetDefaultAsync();
baseDirectory = rootFolder != null ? rootFolder.Path : settings.OutputPath;

audiobook.BasePath = Path.Join(baseDirectory, _fileNamingService.ApplyNamingPattern(settings.FolderNamingPattern, metadata));
audiobook.BasePath = Path.Join(baseDirectory, _fileNamingService.BuildDirectory(NamingContext.From(audiobook), settings));
}
else
{
Expand Down
Loading