Skip to content

fix(importer): name a single-file audiobook from the audiobook file template (#2900) - #2905

Merged
vavallee merged 1 commit into
mainfrom
fix/2900-single-file-audiobook-template
Oct 1, 2026
Merged

vavallee merged 1 commit into
mainfrom
fix/2900-single-file-audiobook-template

Conversation

@vavallee

@vavallee vavallee commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Closes #2900

Summary

With naming.audiobook_file_template set, a folder of tracks was renamed per track but a lone .m4b kept its download name. The template was only read inside if srcInfo.IsDir(); the single-file branch placed the file as filepath.Base(audiobookSource).

This takes option 1 from the issue (Readarr-style conditional part), with one adjustment the code forced:

  • Single file, {Part} in a conditional group: the template renders with {Part} empty and the Feature: Conditional/optional text and width modifiers in naming templates #1127 renderer drops the whole group. {Author} - {Title}{ - Pt. Part:3}.{ext} gives Stephen King - The Shining.m4b, and Stephen King - Doctor Sleep - Pt. 001.m4b for each track of a multi-file book.
  • Single file, bare {Part}: the glue sits outside the braces, so an empty part would leave The Shining - Part .m4b with the default {Title} - Part {Part:3}.{ext} (the issue's own repro template). That is worse than today, so such a template numbers the lone file as part 1: The Shining - Part 001.m4b. The rule reuses the renderer's own simple-group test (simpleGroupRe + widthThenLiteralRe), so it cannot drift from what actually collapses.
  • No template: unchanged, source filename kept.

Same rendering function (applyWithExtra via a shared audiobookFileName) for single and multi-file, so the names differ only in the part. Extension is lowercased exactly as the multi-file flatten does.

Where it applies:

  • Import, single-file branch (lone .m4b). The added "(2)" to dir on import when ebook import dir is the same as the audiobook import dir (regression?) #2686 shared-folder merge and its skip-on-collision now check the templated name.
  • A folder holding one track (the flatten namer now receives the track count). Without this, the same audiobook would be The Shining.m4b from a manual file import and The Shining - Pt. 001.m4b from an auto-import of its folder. Multi-track folders are unchanged.
  • Rename files: singleFileAudiobookDest from fix(importer): move single-file audiobooks into their folder on reorganize (#2894) #2901 calls the same helper, so preview and apply match the import. The Fix Match destination preview routes through it too.
  • Settings validator: it checked for the substring {Part, which rejected every conditional form, so option 1 could not be saved. It now asks the renderer whether a group carries a Part token (importer.AudiobookTemplateHasPart). Still rejects templates with no part.

Not changed:

  • Drop folder (External mode): placeDroppedFormat / dropPlaceAudiobook is a separate placement that never reads the file template, for multi-file either, so scouchman's report on Feature: Audiobook file-level renaming, including part-number naming (Readarr-style) #1126 is not covered here.
  • The literal word Part inside a group is still read as the token ({ - Part Part:3} gives 1 001). Docs and the settings hint use Pt..
  • Default template, no new settings.

Docs: docs/DEPLOYMENT.md explains single-file naming and how {Part} behaves; the settings hint says the same. Changelog fragment included.

How it was verified

  • New tests in internal/importer/single_file_audiobook_template_test.go, plus validator cases in settings_handler_test.go. Run against origin/main sources (new-API unit tests excluded, they cannot compile there):
    • TestImport_SingleFileAudiobookUsesFileTemplate (hardlink/copy/move): folder holds [wwwwg_cd-rip.M4B], want [Jordan B. Peterson - We Who Wrestle with God.m4b]
    • TestImport_SingleFileAudiobookUnconditionalPartIsPartOne: folder holds [source.m4b]
    • TestImport_OneTrackFolderMatchesSingleFileName: got ... - Pt. 001.m4b
    • TestImport_SingleFileAudiobookTemplatedNameCollisionIsSkipped: file placed under its source name beside the seeded one, and move mode consumed the source
    • TestReorganize_SingleFileAudiobookUsesFileTemplate / TestPreviewImportDestination_SingleFileAudiobookUsesFileTemplate: proposed .../my_book_rip.m4b / .../rip.m4b
    • TestValidateSettingValue_AudiobookFileTemplate: {Title}{ - Pt. Part:3}.{ext} rejected
    • Guards that pass on both: ..WithoutTemplateKeepsSourceName, TestImport_MultiTrackFolderStillNumbersEveryTrack
  • Mutations (each compiles under go vet, each fails a named test):
    • single-file template branch skipped: 5 tests fail incl. TestImport_SingleFileAudiobookUsesFileTemplate
    • empty-part collapse disabled (part forced to "1"): TestAudiobookSingleFileName and 5 more
    • bare {Part} left empty: TestAudiobookSingleFileName, ..UnconditionalPartIsPartOne
    • one-track folder uses multi naming: TestImport_OneTrackFolderMatchesSingleFileName
    • reorganize keeps source name: TestReorganize_SingleFileAudiobookUsesFileTemplate, preview test
  • go build ./... && go vet ./..., GOOS=windows go build ./..., go test ./cmd/... ./internal/... (exit 0), golangci-lint run ./internal/importer/... ./internal/api/... (0 issues)
  • web: npm run typecheck, npm run lint, vitest src/pages/settings (238 passed)

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

…emplate (#2900)

With naming.audiobook_file_template set, a folder of tracks was renamed
per track but a lone .m4b kept its source filename: the template was only
read on the directory branch, and the single-file branch placed the file
as filepath.Base(src).

The single-file branch now renders the same template through the same
renderer, via a new Renamer.AudiobookSingleFileName. {Part} renders empty
when every {Part} sits in a #1127 conditional group, so
"{Title}{ - Pt. Part:3}.{ext}" gives "Title.m4b". A bare {Part} keeps its
glue outside the braces and an empty value would leave "Title - Part .m4b"
(the default template), so such a template numbers the file as part 1.
No template keeps the source name exactly as before.

The same naming applies to:
- a folder holding a single track, so the name does not depend on how the
  release was packed (the flatten namer now receives the track count);
- Rename files (singleFileAudiobookDest from #2901) and the Fix Match
  destination preview, which share one Scanner helper with the import.

The settings validator checked for the substring "{Part", which rejected
every conditional form; it now asks the renderer whether a group carries
a Part token. The drop folder (External mode) has its own placement that
never used the file template and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

@github-actions github-actions Bot 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.

Clean fix. I traced through the regex logic and tested edge cases mentally — no issues found.

audiobookPartIsConditional / AudiobookTemplateHasPart correctness (internal/importer/renamer.go:193–222): the interaction between simpleGroupRe, widthThenLiteralRe, and groupWordRe is consistent with how renderGroup already classifies these groups. One minor observation: { - Part Series} (where "Part" is used as a label word, not a token) now passes AudiobookTemplateHasPart and could be saved as a template, whereas the old strings.Contains(value, "{Part") check would have rejected it (no {Part substring). The template would render oddly but wouldn't cause a crash or name collision. Edge case so obscure it's not worth blocking on.

singleAudiobookFileName path safety (scanner_handoff.go:183): the rendered == "" || "." || ".." guard is good. audiobookFileName already calls filepath.Base, so double protection.

One-track folder parity (scanner.go:1271): the count == 1 branch calling AudiobookSingleFileName is reached only inside the tmpl != "" gate, so the no-template path is unchanged.

singleFileAudiobookDest signature change (reorganize.go:220): the call site at line 167 passes author, seriesTitle, seriesNum that proposedPathFor already has in scope — no new DB calls.

Test suite is thorough: all three import modes, collision skip, one-track folder, reorganize preview/apply idempotency, and settings validator. The mutation list in the PR body cross-references named tests for each mutant.

— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.

@codecov

codecov Bot commented Oct 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/importer/scanner_handoff.go 80.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@vavallee
vavallee merged commit d52d0e0 into main Oct 1, 2026
43 checks passed
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.

Single-file audiobook imports ignore the audiobook file naming template

1 participant