Skip to content

fix: apply naming patterns exactly and unify all naming flows (#571)#657

Open
s3ntin3l8 wants to merge 12 commits into
Listenarrs:canaryfrom
s3ntin3l8:571-implicit-naming-patterns
Open

fix: apply naming patterns exactly and unify all naming flows (#571)#657
s3ntin3l8 wants to merge 12 commits into
Listenarrs:canaryfrom
s3ntin3l8:571-implicit-naming-patterns

Conversation

@s3ntin3l8

@s3ntin3l8 s3ntin3l8 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #571

Summary

Naming patterns are now applied exactly as configured — no implicit {Series} folder level and no implicit "Title: Subtitle" combining ("apply given naming patterns exactly", as suggested in the issue). Previously, LibraryController.ComputeAudiobookBaseDirectoryFromPattern injected {Series} into folder patterns that omitted it (and the result was persisted as the audiobook's BasePath, so imported files really landed in the unconfigured series folder), while RenameService and ManualImportController rewrote {Title} to "Title: Subtitle" when the patterns didn't use {Subtitle} (rendered as Title - Subtitle, the originally reported symptom).

As discussed in the issue, the fix expanded into consolidating the previously divergent naming flows behind a single orchestrator, so the per-flow drift that caused this can't recur.

Changes

Added

  • NamingContext (domain record): normalized, source-agnostic naming metadata with From(Audiobook) / From(AudioMetadata) / From(AudibleBookMetadata) factories. Source-specific quirks live in the factories; processing lives in the naming service.
  • IFileNamingService.BuildDirectory / BuildPath: the single naming orchestrator — folder/file/legacy/custom-base pattern application, multi-file sequence suffixes, sanitization, and path-length limits in one place.

Changed

  • All naming flows route through the orchestrator: LibraryController (POST /library/preview-path, bulk root-folder change), ManualImportController, RenameService, LibraryAddService, DownloadImportService. Five hand-rolled variable dictionaries and four copies of the pattern-branching logic are gone.
  • LibraryController's no-pattern fallback is now {Author}/{Series}/{Title}, aligned with the orchestrator's legacy default (was {Author}/{Title}). Only reachable when no folder pattern is configured at all; an empty {Series} collapses away, so non-series books are unaffected.

Fixed

  • Removed the {Series} injection in ComputeAudiobookBaseDirectoryFromPattern — e.g. {Author}/{Title} ({Year}) [{Asin}] no longer previews/persists Tom Clancy/A Jack Ryan Novel (chronological order)/Executive Orders (2010) [B004ESTSSO].
  • Removed the implicit "Title: Subtitle" combining in RenameService and ManualImportController. The explicit {Subtitle} token keeps working.
  • A deliberately slash-less folder pattern (e.g. {Title}) is applied exactly instead of being replaced with {Author}/{Title} — matching how the import pipeline already applied such patterns.
  • Multi-file downloads whose pattern has no {DiskNumber}/{ChapterNumber} token no longer resolve every file to the same name (overwriting each other) — BuildPath appends the stable -NN sequence suffix, matching rename/manual-import behavior.
  • Author-narrated imports (Artist == Narrator in file tags) keep the author instead of collapsing to Unknown Author.
  • UNC (NAS share) destinations survive Windows MAX_PATH enforcement. On Windows, Path.GetPathRoot(@"\\server\share\dir") has no trailing separator and EnsurePathWithinLimits always returns the rebuilt root + join(parts) string, so every UNC path it touched came back as \\server\sharedir\... — even under the limit. Dormant while the method had no production callers, but reachable once BuildPath (which the import/rename flows now use) started calling it. The MAX_PATH logic now sits behind a testable seam with explicit Windows root parsing, so the previously self-skipping path-length tests (and new UNC cases) run on the ubuntu CI runners too.

Removed

  • Controller-local SanitizeDirectoryName, the ApplyNamingPattern(AudioMetadata) / ApplyNamingPattern(AudibleBookMetadata) overloads, and FileNamingService's ChooseAuthor heuristic (the noisy-tag author heuristic remains where it's used, in DownloadImportService.BuildNamingMetadata).

Testing

  • New tests cover: pattern-without-{Series} (incl. the literal reported pattern), slash-less pattern, {SeriesNumber} preserved for series-less books, subtitle not combined in rename + manual import, explicit {Subtitle} still renders, multi-file no-token suffix collision fix, 13 orchestrator tests locking in canonical BuildDirectory/BuildPath behavior, the no-pattern {Author}/{Series}/{Title} fallback (controller + add-to-library service level), and Windows MAX_PATH/UNC handling via the new seam (drive, UNC, and root-parsing cases that previously self-skipped on non-Windows). Full backend suite: 735/735 green.
  • Verified live on a dev instance and a real-library instance: a pattern without {Series} previews Tom Clancy/Executive Orders (2010) [B004ESTSSO]; switching to {Author}/{Series}/{Title} still produces the series folder.

Notes

Behavior changes to be aware of:

  • Users who relied on the implicit additions must add {Series}/{Subtitle} to their patterns. The default folder pattern {Author}/{Series}/{Title} already contains {Series}, so default configurations are unaffected.
  • Organize/rename previews will propose moves for books sitting in previously implicitly-created paths (correct, but visible). Nothing moves without executing the rename.
  • Already-persisted BasePath values are not rewritten retroactively.
  • Manual import now creates an Unknown Author folder for author-less books: the unified builder includes the Author token with the standard "Unknown Author" fallback (matching every other flow); manual import previously omitted blank variables, so author-less batches landed directly under the base path.
  • Colon (:) in titles now renders as - in the Add-book destination preview / persisted BasePath (the consolidated SanitizePathComponent replaces the removed controller-local sanitizer, which mapped :_).

🤖 Generated with Claude Code

@therobbiedavis therobbiedavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks good. I found one edge case in the path cleanup logic though. Once that's buttoned up this should be good to merge. Just FYI we're reviewing multiple PRs at once and haven't put this on our roadmap just yet so you may need to rebase while we review and merge other PRs before yours. Thanks!

Comment thread listenarr.api/Controllers/LibraryController.cs Outdated

@T4g1 T4g1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we need more that that, the original issue arise from the fact that each service kind of handle the pattern naming in its own way, the fix should focus first on reconciliating the pattern handling to a single service or from a centralized place at least

@s3ntin3l8

s3ntin3l8 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@T4g1: You're right that the path handling is duplicated.
The token-substitution step itself is already centralized in FileNamingService.ApplyNamingPattern, every flow calls it. What's re-implemented per service is the path-assembly orchestration and variable-building around it (build the variable dict -> folder/file/legacy branch -> multi-file suffix -> extension). That lives independently in RenameService.BuildExpectedPath, ManualImportController, DownloadImportService, and LibraryController.ComputeAudiobookBaseDirectoryFromPattern / LibraryAddService.AddToLibraryAsync.

I agree consolidating onto one orchestrator is the right move. Should we fold it into this PR (which is just a small bug fix), or adress in a seperate PR?

@therobbiedavis therobbiedavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@s3ntin3l8, I am more prone to expanding the scope slightly to go ahead and do things the proper way than I am separating out things into subtasks/prs.

s3ntin3l8 and others added 3 commits June 9, 2026 21:02
…s/Subtitle (Listenarrs#571)

ComputeAudiobookBaseDirectoryFromPattern injected a {Series} directory
level into the configured folder pattern whenever the audiobook had
series metadata, even when the user deliberately omitted {Series}. The
injected path drives the Add-book destination preview and the bulk
root-folder change, and is persisted as the audiobook BasePath — so
imported files really landed in an unconfigured series folder.

RenameService and ManualImportController similarly rewrote the {Title}
variable to "Title: Subtitle" when the patterns lacked {Subtitle}
(sanitized to "Title - Subtitle" in paths).

Patterns are now applied verbatim; users who want series folders or
subtitles add the {Series}/{Subtitle} tokens explicitly. The default
folder pattern already contains {Series}, so default configurations
are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation (Listenarrs#571)

A deliberately flat folder pattern (e.g. "{Title}") was silently
replaced with "{Author}/{Title}" when computing an audiobook's base
directory for the destination preview and bulk root-folder change.
The import pipeline (FileNamingService) already applies such patterns
exactly, so the preview did not match where files actually went.

The "{Author}/{Title}" fallback now only applies when no pattern is
configured at all (or token stripping leaves it empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enarrs#571)

The "remove {Series} when the book has no series" cleanup in
ComputeAudiobookBaseDirectoryFromPattern used the regex \{Series[^}]*\},
which also matched {SeriesNumber}/{SeriesNumber:00} — so a configured
pattern like {Author}/{SeriesNumber}/{Title} lost its number for
non-series books.

Remove the textual strip block entirely and let ApplyNamingPattern
handle the empty {Series} value (it already substitutes empty tokens and
collapses the surrounding separators — the single cleanup path the rest
of this change relies on). {SeriesNumber} is now preserved; the existing
non-series test ({Author}/{Series}/{Title} -> Author/Title) still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@s3ntin3l8 s3ntin3l8 force-pushed the 571-implicit-naming-patterns branch from 719d9d7 to 57b898e Compare June 9, 2026 21:11
s3ntin3l8 and others added 9 commits June 9, 2026 21:22
…#571)

Introduce a single path-assembly orchestrator in FileNamingService —
BuildDirectory/BuildPath over a normalized NamingContext — so the
per-flow naming duplication can be consolidated. NamingContext.From()
factories map Audiobook/AudioMetadata/AudibleBookMetadata into one shape;
source-specific quirks (noisy-tag author selection) live in the mappers,
while sanitization, token substitution, the folder/file/legacy branch and
path-length limits live once in the service.

GenerateFilePathAsync now delegates to BuildPath, so its existing ~40
tests exercise the orchestrator. No behavior change yet — the individual
flows are rerouted onto BuildDirectory/BuildPath in follow-up commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#571)

Replace the per-flow variable builders and the duplicated
folder/file/legacy branch with the single NamingContext +
BuildDirectory/BuildPath orchestrator:

- LibraryAddService + LibraryController.ComputeAudiobookBaseDirectoryFromPattern
  -> BuildDirectory / ApplyNamingPattern(NamingContext)
- RenameService.BuildExpectedPath -> BuildPath
- ManualImportController -> BuildPath
- DownloadImportService -> ApplyNamingPattern(NamingContext), dropping its
  private invalid-char sanitizer (the shared sanitizer covers it)

Delete the now-dead duplicates: SanitizeDirectoryName, per-flow
BuildNamingVariables/PatternAllowsSubfolders copies, and the unused
AudioMetadata/AudibleBookMetadata ApplyNamingPattern + BuildVariables
overloads and ChooseAuthor heuristic (folded into NamingContext).

Canonical behavior is now uniform across flows: one strong
SanitizePathComponent (":" -> " - "), case-insensitive tokens,
"Unknown Author" fallback, and consistent legacy/custom-base/folder+file
handling. Manual import now supports {SeriesNumber}/{Quality} and uses the
"Unknown Author" folder for author-less books (its multi-file ordering
test updated to match).

Full backend suite green (705 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Direct BuildDirectory/BuildPath tests covering the canonical decisions:
case-insensitive tokens, ":" -> " - " sanitization, "Unknown Author"
fallback, empty-{Series} separator collapse, multi-file sequence suffix,
and custom-base-path file-only naming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rrs#571)

The consolidation routed DownloadImportService through
NamingContext.From(AudioMetadata), which had folded in the
narrator-avoidance heuristic from the (dead-in-prod) GenerateFilePathAsync.
That heuristic drops the author when Artist == Narrator — common for
self-narrated memoirs — sending those imports to "Unknown Author/".

Take the author straight from the Artist tag (matching the historical
auto-import behavior) and drop the heuristic. Adds a regression test for
the author-narrated case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review finding #1: DownloadImport had consolidated its variable
building onto NamingContext but still assembled the destination with direct
ApplyNamingPattern calls, bypassing the shared orchestrator. Route it
through BuildPath like the other flows — a set BasePath means file-only
naming (IsCustomBasePath), an empty BasePath applies the folder pattern.

This also brings EnsurePathWithinLimits to the auto-import path and adopts
the multi-file sequence suffix, fixing a latent overwrite when a multi-file
download uses a pattern without {DiskNumber}/{ChapterNumber}.

Remove the now-dead folderPattern/destDirForFile/baseFilePattern/
patternAllowsSubfolders/treatAsFilename locals. Add a regression test for
the suffix collision fix, and a doc note on the AudibleBookMetadata ->
ToAudiobook coupling (review finding Listenarrs#5).

Full backend suite green (713 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enarrs#571)

Review item 7: with an empty FolderNamingPattern, BuildDirectory rendered
the whole FileNamingPattern as a directory (over-nesting the filename
segment as a folder), while BuildPath's legacy branch treats it as a full
relative path. Derive the directory portion in BuildDirectory's fallback so
the two agree:
  BuildDirectory(ctx) == Path.GetDirectoryName(BuildPath(ctx).RelativePath)

Only LibraryAddService uses BuildDirectory, and only the empty-pattern edge
case changes. Adds tests for the consistency invariant plus the review's
flagged gaps: BuildPath legacy fallback, multi-file + {DiskNumber} token,
From(Authors=null), and From(AudibleBookMetadata) mapping.

Full suite green (719 tests; Category=FileNamingService = 36).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

- Remove the two Listenarrs#571 CHANGELOG entries — changelog entries are no longer
  wanted on upstream PRs (maintainer confirmed on Listenarrs#659/Listenarrs#660); they only
  cause [Unreleased] conflicts. Keeps the existing Authentication entries.
- Unify LibraryController.ComputeAudiobookBaseDirectoryFromPattern's
  no-pattern fallback from {Author}/{Title} to {Author}/{Series}/{Title},
  matching the orchestrator's legacy default (review follow-up). Reachable
  only when no folder pattern is configured at all; empty {Series} collapses
  away, so non-series books are unaffected.

719 backend tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stenarrs#571)

Review follow-ups: the {Author}/{Series}/{Title} no-pattern fallback in
ComputeAudiobookBaseDirectoryFromPattern (unified in the previous commit)
had no test — every existing case passed an explicit pattern. Also adds a
service-level test for LibraryAddService deriving BasePath from the file
naming pattern's directory portion when no folder pattern is configured
(BuildDirectory's legacy branch, previously only covered at unit level).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stable (Listenarrs#571)

Pull the MAX_PATH truncation logic behind a testable seam
(EnforceWindowsPathLimits) that uses explicit Windows path semantics —
separators and drive/UNC root parsing — instead of the platform-dependent
System.IO.Path helpers. The previously self-skipping path-length tests now
run on any OS (including the ubuntu-only CI), and UNC (NAS share) inputs
are covered.

Writing the UNC coverage exposed a real bug: on Windows,
Path.GetPathRoot(@"\\server\share\dir") returns the root without a
trailing separator, and the function always returns the rebuilt
root + join(parts) string — so every UNC path it touched came back as
\\server\sharedir\..., even when under the limit. This was dormant while
EnsurePathWithinLimits had no production callers, but routing manual
import, rename, and download import through BuildPath made it reachable;
fixed by including the separator after the share in the parsed root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@s3ntin3l8 s3ntin3l8 changed the title fix: apply configured naming patterns exactly, without implicit {Series}/{Subtitle} fix: apply naming patterns exactly and unify all naming flows (#571) Jun 10, 2026
@s3ntin3l8

s3ntin3l8 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@T4g1 @therobbiedavis The consolidation discussed above is implemented and pushed.

What changed:

  • New NamingContext (domain record) with From(Audiobook / AudioMetadata / AudibleBookMetadata) factories, and a single orchestrator in FileNamingService: BuildDirectory (folder-only, for BasePath) and BuildPath (folder + file patterns, multi-file suffixes, sanitization, MAX_PATH).
  • All five flows now route through it - RenameService, ManualImportController, DownloadImportService, LibraryController (preview-path / bulk root-folder), LibraryAddService. The per-service variable dicts, branch logic, and the duplicate sanitizer are deleted, so the per-flow drift that caused Implicit patterns added to configured naming patterns #571 can't recur.

Consolidation flushed out two latent bugs, both fixed with regression tests:

  • Multi-file downloads whose pattern has no {DiskNumber}/{ChapterNumber} token resolved every file to the same name (silent overwrite); BuildPath now appends the stable -NN suffix like rename/manual import already did.
  • Windows MAX_PATH enforcement mangled UNC destinations (\\server\share\dir -> \\server\sharedir\…) because Path.GetPathRoot's UNC root has no trailing separator. The limit logic now sits behind a small testable seam with explicit Windows root parsing, so the path-length tests (incl. new UNC cases) run on the ubuntu CI runners instead of self-skipping.

Full backend suite is 735/735 green. The PR description is rewritten to the template and lists the (opt-in) behavior changes - happy to adjust anything that doesn't match what you had in mind.

@s3ntin3l8 s3ntin3l8 requested review from T4g1 and therobbiedavis June 10, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implicit patterns added to configured naming patterns

3 participants