Skip to content
Merged
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
2 changes: 2 additions & 0 deletions changelog.d/2900-single-file-audiobook-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- **A single-file audiobook is now named from the audiobook file template** (#2900): with `naming.audiobook_file_template` set, a folder of tracks was renamed but a lone `.m4b` kept its download name. It now gets the same name a track would, and so does a folder holding just one track. Put `{Part}` in a group with its own text, like `{Title}{ - Pt. Part:3}.{ext}`, and the group is left out when there is only one file, giving `The Shining.m4b`; written bare, as in `{Title} - Part {Part:3}.{ext}`, the single file is numbered as part 1. Settings now accepts `{Part}` inside such a group, which it used to reject. **Rename files** and the Fix Match preview propose the same name for a single-file audiobook already in the library. With no template set nothing changes. Thanks zikeji for the report.
15 changes: 14 additions & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,23 @@ Two settings opt out of the merge: **Flatten multi-disc audiobooks** and a
per-file audiobook naming template both keep the historical behaviour and place
the audiobook in a sibling `Title (2)` folder. So does a download whose
audiobook files do not share a folder of their own, which Bindery places file
by file.
by file. A lone audiobook file such as a single `.m4b` still merges with a
naming template set; it is named from the template, and a file already there
under that name is skipped the same way.
(For handing files to Storyteller's *watch folder* instead,
see [Handing off to another library tool](#handing-off-to-another-library-tool-cwa-calibre-storyteller).)

The per-file audiobook naming template (`naming.audiobook_file_template`)
renames a single-file audiobook too, whether it arrives as a lone `.m4b` or as
a folder holding one track, and **Rename files** proposes the same name for one
already in the library. `{Part}` decides how it reads. In a group with its own
text, such as `{Title}{ - Pt. Part:3}.{ext}`, the group is left out for a single
file (`The Shining.m4b`) and numbered for several (`Doctor Sleep - Pt. 001.m4b`).
Written bare, as in the default `{Title} - Part {Part:3}.{ext}`, the single file
is numbered as part 1 (`The Shining - Part 001.m4b`). Inside a group every word
that is a token name is read as the token, so write `Pt.` rather than `Part` for
the label there.

### `BINDERY_DOWNLOAD_DIR` is not a watch folder

A common misreading: `BINDERY_DOWNLOAD_DIR` does **not** mean "all completed
Expand Down
8 changes: 6 additions & 2 deletions internal/api/settings_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/vavallee/bindery/internal/calibre"
"github.com/vavallee/bindery/internal/db"
"github.com/vavallee/bindery/internal/httpsec"
"github.com/vavallee/bindery/internal/importer"
"github.com/vavallee/bindery/internal/metadata/hardcover"
"github.com/vavallee/bindery/internal/models"
"github.com/vavallee/bindery/internal/pathmap"
Expand Down Expand Up @@ -669,11 +670,14 @@ func validateSettingValue(key, value string) error {
case SettingNamingAudiobookFileTemplate:
// Empty disables per-file audiobook renaming (#1126). A non-empty
// template MUST carry a {Part} token, otherwise every track flattens to
// the same filename and all but the last are dropped.
// the same filename and all but the last are dropped. The token may
// sit in a conditional group ("{Title}{ - Pt. Part:3}.{ext}"), which a
// single-file audiobook drops entirely (#2900), so this asks the
// renderer rather than looking for the substring "{Part".
if value == "" {
return nil
}
if !strings.Contains(value, "{Part") {
if !importer.AudiobookTemplateHasPart(value) {
return fmt.Errorf("naming.audiobook_file_template must include a {Part} token so each track gets a unique name")
}
case SettingImportDropLinkMode:
Expand Down
3 changes: 3 additions & 0 deletions internal/api/settings_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,9 @@ func TestValidateSettingValue_AudiobookFileTemplate(t *testing.T) {
"{Title} - Part {Part:3}.{ext}",
"{Part}.{ext}",
"{Author} - {Title} - {Part:2}.{ext}",
// A conditional group, which a single-file audiobook drops (#2900).
"{Title}{ - Pt. Part:3}.{ext}",
"{Title}{ - Part:3}.{ext}",
} {
if err := validateSettingValue(SettingNamingAudiobookFileTemplate, v); err != nil {
t.Errorf("%q should be accepted: %v", v, err)
Expand Down
4 changes: 2 additions & 2 deletions internal/importer/audiobook_naming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestFlattenAudiobookDirNamed_Ordering(t *testing.T) {
r := NewRenamer("")
book := &models.Book{Title: "Recursion"}
author := &models.Author{Name: "Blake Crouch"}
namer := func(index int, ext string) string {
namer := func(index, _ int, ext string) string {
return r.AudiobookFileName("{Title} - Part {Part:3}.{ext}", author, book, "", "", trimDot(ext), index+1)
}

Expand Down Expand Up @@ -86,7 +86,7 @@ func TestFlattenAudiobookDirNamed_Ordering(t *testing.T) {
func TestFlattenAudiobookDirNamed_DuplicateNameFails(t *testing.T) {
src := buildMultiDiscTree(t)
dst := filepath.Join(t.TempDir(), "out")
namer := func(index int, ext string) string { return "same" + ext } // no {Part}
namer := func(_, _ int, ext string) string { return "same" + ext } // no {Part}
if err := flattenAudiobookDirNamed(context.Background(), "copy", src, dst, namer); err == nil {
t.Error("expected a duplicate-name error when the namer omits Part")
}
Expand Down
12 changes: 7 additions & 5 deletions internal/importer/flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,19 @@ func isMultiDiscAudiobook(srcRoot string) bool {
// no separators), so book-derived strings cannot escape the library.
func flattenAudiobookDir(ctx context.Context, mode, srcRoot, destDir string) error {
// Default "Part NNN.ext" naming: mirrors the historical multi-disc flatten.
return flattenAudiobookDirNamed(ctx, mode, srcRoot, destDir, func(index int, ext string) string {
return flattenAudiobookDirNamed(ctx, mode, srcRoot, destDir, func(index, _ int, ext string) string {
return fmt.Sprintf("Part %03d%s", index+1, ext)
})
}

// flattenAudiobookDirNamed is flattenAudiobookDir with a caller-supplied namer,
// so both the built-in "Part NNN" multi-disc flatten and the opt-in per-file
// audiobook naming template (#1126) share one deterministic placement path.
// nameFor receives the 0-based playback index and the lowercased source
// extension (with leading dot) and returns the destination basename.
func flattenAudiobookDirNamed(ctx context.Context, mode, srcRoot, destDir string, nameFor func(index int, ext string) string) error {
// nameFor receives the 0-based playback index, the number of tracks, and the
// lowercased source extension (with leading dot) and returns the destination
// basename. The count lets the template namer give a folder holding a single
// track the same name as a lone audiobook file (#2900).
func flattenAudiobookDirNamed(ctx context.Context, mode, srcRoot, destDir string, nameFor func(index, count int, ext string) string) error {
if mode != "copy" && mode != "hardlink" {
return fmt.Errorf("flatten supports copy/hardlink only, got %q", mode)
}
Expand All @@ -205,7 +207,7 @@ func flattenAudiobookDirNamed(ctx context.Context, mode, srcRoot, destDir string
return err
}
ext := strings.ToLower(filepath.Ext(tr.src))
name := filepath.Base(nameFor(i, ext))
name := filepath.Base(nameFor(i, len(tracks), ext))
if _, clash := reserved[name]; clash {
// A template that omits {Part} would collapse every track to one
// name and silently drop all but the last. Fail loudly instead.
Expand Down
66 changes: 65 additions & 1 deletion internal/importer/renamer.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,81 @@ func (r *Renamer) DestPath(rootFolder string, author *models.Author, book *model
// ext is the source extension WITHOUT a leading dot (the template supplies the
// dot before {ext}, matching the ebook template convention).
func (r *Renamer) AudiobookFileName(template string, author *models.Author, book *models.Book, series, seriesNumber, ext string, part int) string {
return r.audiobookFileName(template, author, book, series, seriesNumber, ext, strconv.Itoa(part))
}

// AudiobookSingleFileName renders the per-file audiobook naming template for
// an audiobook that is one audio file (#2900): a lone .m4b, or a folder holding
// a single track. It is the same render AudiobookFileName does for a track of a
// multi-file book, so the two differ only in the part.
//
// {Part} renders empty when every {Part} in the template sits in a conditional
// group with its own text, so the #1127 collapse drops the whole group:
// "{Title}{ - Pt. Part:3}.{ext}" gives "Title.m4b" here and "Title - Pt. 001.m4b"
// for each track of a multi-file book. A bare "{Part}" or "{Part:3}" has its
// glue outside the braces, and an empty value there leaves it dangling
// ("Title - Part .m4b" from the default template), so such a template numbers
// the lone file as part 1, exactly as it would the first of several.
func (r *Renamer) AudiobookSingleFileName(template string, author *models.Author, book *models.Book, series, seriesNumber, ext string) string {
if template == "" {
template = defaultAudiobookFileTemplate
}
part := ""
if !audiobookPartIsConditional(template) {
part = "1"
}
return r.audiobookFileName(template, author, book, series, seriesNumber, ext, part)
}

func (r *Renamer) audiobookFileName(template string, author *models.Author, book *models.Book, series, seriesNumber, ext, part string) string {
if template == "" {
template = defaultAudiobookFileTemplate
}
name := r.applyWithExtra(template, author, book, series, seriesNumber, ext, map[string]string{
"Part": strconv.Itoa(part),
"Part": part,
})
// Defensive: collapse any separator a hand-edited template might introduce
// so a track can never escape the destination directory.
return filepath.Base(name)
}

// audiobookPartIsConditional reports whether every {Part} in template is in a
// conditional group, one renderGroup collapses to "" when the part is empty.
// It is the negation of renderGroup's simple-form test: a group whose content
// is "Part" with an optional default or width modifier renders the bare value,
// so an empty part there cannot take its surrounding text with it. A template
// with no {Part} at all is trivially conditional.
func audiobookPartIsConditional(template string) bool {
for _, g := range templateGroupRe.FindAllStringSubmatch(template, -1) {
if m := simpleGroupRe.FindStringSubmatch(g[1]); m != nil && !widthThenLiteralRe.MatchString(m[2]) && m[1] == "Part" {
return false
}
}
return true
}

// AudiobookTemplateHasPart reports whether template carries a {Part} token the
// renderer substitutes, either as a group of its own ("{Part:3}") or inside a
// conditional group with literal text ("{ - Pt. Part:3}"). The settings
// validator uses it so the conditional form a single-file audiobook collapses
// (#2900) can be saved; a plain substring test for "{Part" rejected it.
func AudiobookTemplateHasPart(template string) bool {
for _, g := range templateGroupRe.FindAllStringSubmatch(template, -1) {
if m := simpleGroupRe.FindStringSubmatch(g[1]); m != nil && !widthThenLiteralRe.MatchString(m[2]) {
if m[1] == "Part" {
return true
}
continue
}
for _, w := range groupWordRe.FindAllStringSubmatch(g[1], -1) {
if w[1] == "Part" {
return true
}
}
}
return false
}

// AudiobookDestDir computes the destination directory into which an audiobook
// download folder should be moved. The download's internal file structure is
// preserved inside (multi-part m4b/mp3 + cover + cue sheet stay together).
Expand Down
10 changes: 6 additions & 4 deletions internal/importer/reorganize.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func (s *Scanner) proposedPathFor(ctx context.Context, book *models.Book, author
if flattenedOntoFolderPath(f.Path, dest) {
return dest, ReorgStatusError, flattenedOntoFolderMsg
}
dest = s.singleFileAudiobookDest(ctx, book, dest, f.Path)
dest = s.singleFileAudiobookDest(ctx, book, author, seriesTitle, seriesNum, dest, f.Path)
} else {
dest = uniqueDirExcluding(dest, f.Path)
}
Expand Down Expand Up @@ -214,12 +214,14 @@ func isSingleFile(path string) bool {

// singleFileAudiobookDest mirrors the single-file import branch: join the
// book's own ebook folder (#2686), otherwise uniquify with the file's current
// folder treated as available so a file already in place stays a noop.
func (s *Scanner) singleFileAudiobookDest(ctx context.Context, book *models.Book, destDir, current string) string {
// folder treated as available so a file already in place stays a noop. The
// file is named the way the import names it (#2900): from the audiobook file
// template when one is set, otherwise its own name.
func (s *Scanner) singleFileAudiobookDest(ctx context.Context, book *models.Book, author *models.Author, seriesTitle, seriesNum, destDir, current string) string {
if existing, merging := s.existingEbookDir(ctx, book); !merging || filepath.Clean(destDir) != existing {
destDir = uniqueDirExcluding(destDir, filepath.Dir(current))
}
return filepath.Join(destDir, filepath.Base(current))
return filepath.Join(destDir, s.singleAudiobookFileName(ctx, author, book, seriesTitle, seriesNum, current))
}

const flattenedOntoFolderMsg = "this audiobook file sits where its folder belongs and has lost its extension (#2894). " +
Expand Down
13 changes: 11 additions & 2 deletions internal/importer/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -1789,7 +1789,13 @@ func (s *Scanner) tryImportInternal(ctx context.Context, dl *models.Download, do
if mode == "move" {
flattenMode = "copy"
}
namer := func(index int, ext string) string {
namer := func(index, count int, ext string) string {
// A folder holding one track is a single-file
// audiobook too, so it gets the same name a lone
// .m4b does on the branch below (#2900).
if count == 1 {
return s.renamer.AudiobookSingleFileName(tmpl, author, book, seriesTitle, seriesNum, strings.TrimPrefix(ext, "."))
}
return s.renamer.AudiobookFileName(tmpl, author, book, seriesTitle, seriesNum, strings.TrimPrefix(ext, "."), index+1)
}
slog.Info("renaming audiobook files per template", "src", audiobookSource, "dst", destDir, "mode", flattenMode, "template", tmpl)
Expand Down Expand Up @@ -1892,7 +1898,10 @@ func (s *Scanner) tryImportInternal(ctx context.Context, dl *models.Download, do
if err := os.MkdirAll(destDir, 0o750); err != nil {
dirErr = fmt.Errorf("create audiobook dest dir: %w", err)
} else {
name := filepath.Base(audiobookSource)
name := s.singleAudiobookFileName(ctx, author, book, seriesTitle, seriesNum, audiobookSource)
if name != filepath.Base(audiobookSource) {
slog.Info("renaming audiobook file per template", "src", audiobookSource, "dst", destDir, "name", name, "mode", mode)
}
dstFile := filepath.Join(destDir, name)
// A merge never overwrites what is already in the book's
// folder, the same contract CopyDirMergeCtx and friends
Expand Down
23 changes: 23 additions & 0 deletions internal/importer/scanner_handoff.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,29 @@ func (s *Scanner) audiobookFileTemplate(ctx context.Context) string {
return strings.TrimSpace(setting.Value)
}

// singleAudiobookFileName is the name a single-file audiobook takes inside its
// folder: the source file's own name, or, when naming.audiobook_file_template
// is set, that template rendered the way the folder branch renders a track,
// with {Part} left out as AudiobookSingleFileName describes (#2900). The
// import's single-file branch and Rename files both call it, so a reorganized
// file lands where a fresh import would put it. The extension is lowercased
// for the template, the same as flattenAudiobookDirNamed does for each track.
// A template that renders to nothing usable keeps the source name rather than
// placing a file called "." or with no name at all.
func (s *Scanner) singleAudiobookFileName(ctx context.Context, author *models.Author, book *models.Book, seriesTitle, seriesNum, src string) string {
name := filepath.Base(src)
tmpl := s.audiobookFileTemplate(ctx)
if tmpl == "" {
return name
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(src)), ".")
rendered := s.renamer.AudiobookSingleFileName(tmpl, author, book, seriesTitle, seriesNum, ext)
if rendered == "" || rendered == "." || rendered == ".." || rendered == string(filepath.Separator) {
return name
}
return rendered
}

// pushToCWA copies the just-imported file into the directory watched by a
// sibling Calibre-Web-Automated container, when the cwa.ingest_path setting
// is configured. CWA's auto-ingest deletes whatever lands in that folder
Expand Down
Loading
Loading