fix(importer): name a single-file audiobook from the audiobook file template (#2900) - #2905
Conversation
…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>
There was a problem hiding this comment.
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Closes #2900
Summary
With
naming.audiobook_file_templateset, a folder of tracks was renamed per track but a lone.m4bkept its download name. The template was only read insideif srcInfo.IsDir(); the single-file branch placed the file asfilepath.Base(audiobookSource).This takes option 1 from the issue (Readarr-style conditional part), with one adjustment the code forced:
{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}givesStephen King - The Shining.m4b, andStephen King - Doctor Sleep - Pt. 001.m4bfor each track of a multi-file book.{Part}: the glue sits outside the braces, so an empty part would leaveThe Shining - Part .m4bwith 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.Same rendering function (
applyWithExtravia a sharedaudiobookFileName) 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:
.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.The Shining.m4bfrom a manual file import andThe Shining - Pt. 001.m4bfrom an auto-import of its folder. Multi-track folders are unchanged.singleFileAudiobookDestfrom 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.{Part, which rejected every conditional form, so option 1 could not be saved. It now asks the renderer whether a group carries aParttoken (importer.AudiobookTemplateHasPart). Still rejects templates with no part.Not changed:
placeDroppedFormat/dropPlaceAudiobookis 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.Partinside a group is still read as the token ({ - Part Part:3}gives1 001). Docs and the settings hint usePt..Docs:
docs/DEPLOYMENT.mdexplains single-file naming and how{Part}behaves; the settings hint says the same. Changelog fragment included.How it was verified
internal/importer/single_file_audiobook_template_test.go, plus validator cases insettings_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.m4bTestImport_SingleFileAudiobookTemplatedNameCollisionIsSkipped: file placed under its source name beside the seeded one, and move mode consumed the sourceTestReorganize_SingleFileAudiobookUsesFileTemplate/TestPreviewImportDestination_SingleFileAudiobookUsesFileTemplate: proposed.../my_book_rip.m4b/.../rip.m4bTestValidateSettingValue_AudiobookFileTemplate:{Title}{ - Pt. Part:3}.{ext}rejected..WithoutTemplateKeepsSourceName,TestImport_MultiTrackFolderStillNumbersEveryTrackgo vet, each fails a named test):TestImport_SingleFileAudiobookUsesFileTemplateTestAudiobookSingleFileNameand 5 more{Part}left empty:TestAudiobookSingleFileName,..UnconditionalPartIsPartOneTestImport_OneTrackFolderMatchesSingleFileNameTestReorganize_SingleFileAudiobookUsesFileTemplate, preview testgo build ./... && go vet ./...,GOOS=windows go build ./...,go test ./cmd/... ./internal/...(exit 0),golangci-lint run ./internal/importer/... ./internal/api/...(0 issues)npm run typecheck,npm run lint, vitestsrc/pages/settings(238 passed)🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9