Skip to content

Commit 1cda863

Browse files
author
sgerner
committed
make audiobook organization recursive
1 parent 40f7a86 commit 1cda863

2 files changed

Lines changed: 114 additions & 16 deletions

File tree

internal/organize/audio_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package organize
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
7+
8+
"github.com/JeremiahM37/librarr/internal/config"
59
)
610

711
func TestParseAudioFilename(t *testing.T) {
@@ -95,3 +99,69 @@ func TestExtractAudioMetaFromDir_NonexistentDir(t *testing.T) {
9599
t.Error("expected nil for nonexistent directory")
96100
}
97101
}
102+
103+
func TestOrganizeAudiobookMissingSourceDoesNotCreateDestDir(t *testing.T) {
104+
root := t.TempDir()
105+
cfg := &config.Config{
106+
FileOrgEnabled: true,
107+
AudiobookDir: filepath.Join(root, "audiobooks"),
108+
}
109+
o := NewOrganizer(cfg)
110+
111+
missing := filepath.Join(root, "incoming", "missing.m4b")
112+
_, err := o.OrganizeAudiobook(missing, "Missing Book", "Missing Author")
113+
if err == nil {
114+
t.Fatal("expected error")
115+
}
116+
117+
destDir := filepath.Join(cfg.AudiobookDir, "Missing Author", "Missing Book")
118+
if _, statErr := os.Stat(destDir); !os.IsNotExist(statErr) {
119+
t.Fatalf("expected no dest dir on failure, stat err=%v", statErr)
120+
}
121+
}
122+
123+
func TestOrganizeAudiobookMovesNestedTreeRecursively(t *testing.T) {
124+
root := t.TempDir()
125+
src := filepath.Join(root, "incoming", "book")
126+
cd1 := filepath.Join(src, "CD1")
127+
cd2 := filepath.Join(src, "CD2", "Extras")
128+
129+
if err := os.MkdirAll(cd1, 0755); err != nil {
130+
t.Fatal(err)
131+
}
132+
if err := os.MkdirAll(cd2, 0755); err != nil {
133+
t.Fatal(err)
134+
}
135+
if err := os.WriteFile(filepath.Join(cd1, "track01.m4b"), []byte("cd1"), 0644); err != nil {
136+
t.Fatal(err)
137+
}
138+
if err := os.WriteFile(filepath.Join(cd2, "track02.m4b"), []byte("cd2"), 0644); err != nil {
139+
t.Fatal(err)
140+
}
141+
142+
cfg := &config.Config{
143+
FileOrgEnabled: true,
144+
AudiobookDir: filepath.Join(root, "audiobooks"),
145+
}
146+
o := NewOrganizer(cfg)
147+
148+
dest, err := o.OrganizeAudiobook(src, "Nested Book", "Recursive Author")
149+
if err != nil {
150+
t.Fatalf("organize failed: %v", err)
151+
}
152+
153+
wantRoot := filepath.Join(cfg.AudiobookDir, "Recursive Author", "Nested Book")
154+
if dest != wantRoot {
155+
t.Fatalf("dest = %q, want %q", dest, wantRoot)
156+
}
157+
158+
if _, err := os.Stat(filepath.Join(wantRoot, "CD1", "track01.m4b")); err != nil {
159+
t.Fatalf("expected CD1 track at destination: %v", err)
160+
}
161+
if _, err := os.Stat(filepath.Join(wantRoot, "CD2", "Extras", "track02.m4b")); err != nil {
162+
t.Fatalf("expected nested track at destination: %v", err)
163+
}
164+
if _, err := os.Stat(src); !os.IsNotExist(err) {
165+
t.Fatalf("expected source tree removed, stat err=%v", err)
166+
}
167+
}

internal/organize/pipeline.go

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -81,34 +81,27 @@ func (o *Organizer) OrganizeAudiobook(filePath, title, author string) (string, e
8181
author = "Unknown"
8282
}
8383

84-
safeAuthor := sanitizePath(author, 80)
85-
safeTitle := sanitizePath(title, 80)
86-
87-
destDir := filepath.Join(o.cfg.AudiobookDir, safeAuthor, safeTitle)
88-
if err := os.MkdirAll(destDir, 0755); err != nil {
89-
return filePath, err
90-
}
91-
9284
// If source is a directory, move its contents.
9385
info, err := os.Stat(filePath)
9486
if err != nil {
9587
return filePath, err
9688
}
9789

90+
safeAuthor := sanitizePath(author, 80)
91+
safeTitle := sanitizePath(title, 80)
92+
93+
destDir := filepath.Join(o.cfg.AudiobookDir, safeAuthor, safeTitle)
9894
if info.IsDir() {
99-
entries, err := os.ReadDir(filePath)
100-
if err != nil {
95+
if err := moveDirTree(filePath, destDir); err != nil {
10196
return filePath, err
10297
}
103-
for _, entry := range entries {
104-
src := filepath.Join(filePath, entry.Name())
105-
dst := filepath.Join(destDir, entry.Name())
106-
_ = moveFile(src, dst)
107-
}
108-
_ = os.RemoveAll(filePath)
10998
return destDir, nil
11099
}
111100

101+
if err := os.MkdirAll(destDir, 0755); err != nil {
102+
return filePath, err
103+
}
104+
112105
destPath := filepath.Join(destDir, filepath.Base(filePath))
113106
if err := moveFile(filePath, destPath); err != nil {
114107
return filePath, err
@@ -238,6 +231,41 @@ func moveFile(src, dst string) error {
238231
return os.Remove(src)
239232
}
240233

234+
func moveDirTree(srcDir, dstDir string) error {
235+
if err := os.MkdirAll(dstDir, 0755); err != nil {
236+
return err
237+
}
238+
239+
err := filepath.WalkDir(srcDir, func(path string, d os.DirEntry, walkErr error) error {
240+
if walkErr != nil {
241+
return walkErr
242+
}
243+
if path == srcDir {
244+
return nil
245+
}
246+
247+
rel, err := filepath.Rel(srcDir, path)
248+
if err != nil {
249+
return err
250+
}
251+
252+
dstPath := filepath.Join(dstDir, rel)
253+
if d.IsDir() {
254+
return os.MkdirAll(dstPath, 0755)
255+
}
256+
257+
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
258+
return err
259+
}
260+
return moveFile(path, dstPath)
261+
})
262+
if err != nil {
263+
return err
264+
}
265+
266+
return os.RemoveAll(srcDir)
267+
}
268+
241269
// copyFileForOrg copies a file without removing the source.
242270
func copyFileForOrg(src, dst string) error {
243271
srcFile, err := os.Open(src)

0 commit comments

Comments
 (0)