package execute import ( "context" "fmt" "io" "os" "path/filepath" "strings" "intake/internal/artwork" "intake/internal/audio" "intake/internal/plan" "intake/internal/tags" ) type Options struct { DryRun bool KeepStaging bool VerifyDecode string Collision string CoverPath string CoverName string // StagingRoot is the staging directory the album came from. Staging cleanup // never removes it, however empty it becomes. StagingRoot string Logf func(format string, args ...any) } type Result struct { ArchiveDir string Files []string RemovedStaging bool Bytes int64 StagingLeftovers []string // StagingStuck names staged files that could not be removed. The album is // archived either way. StagingStuck []string } func Run(ctx context.Context, p plan.Plan, opts Options) (Result, error) { logf := opts.Logf if logf == nil { logf = func(string, ...any) {} } var res Result if p.ArchiveDir == "" { return res, fmt.Errorf("plan has no archive directory") } res.ArchiveDir = p.ArchiveDir if opts.DryRun { logf("dry run: would create %s", p.ArchiveDir) if opts.CoverPath != "" { logf("dry run: would write %s and embed it as the front cover", coverName(opts)) } for _, t := range p.Tracks { logf("dry run: copy %s -> %s/%s", filepath.Base(t.Source), filepath.Base(p.ArchiveDir), t.Target) } return res, nil } parent := filepath.Dir(p.ArchiveDir) createdParent := false if _, err := os.Stat(parent); os.IsNotExist(err) { createdParent = true } if err := os.MkdirAll(parent, 0o755); err != nil { return res, err } promoted := false defer func() { // An artist folder is only created so the album can go inside it. When // the album never gets there -- a truncated download, a decode that // fails verification -- the empty folder is left behind, and an empty // artist directory in the archive is indistinguishable from one whose // albums were lost. os.Remove refuses to touch a non-empty directory, so // this can only ever undo the creation above. if createdParent && !promoted { os.Remove(parent) } }() // Last line of defence against writing one track over another. The plan // checks this too, but the plan is a value that any caller can assemble, and // the cost of being wrong here is a quietly missing track rather than a // refusal, so it is worth proving again immediately before the first copy. if err := checkTargets(p); err != nil { return res, err } tmp, err := os.MkdirTemp(parent, "."+filepath.Base(p.ArchiveDir)+".intake-") if err != nil { return res, err } defer os.RemoveAll(tmp) if opts.CoverPath != "" { if err := copyFile(opts.CoverPath, filepath.Join(tmp, coverName(opts))); err != nil { return res, fmt.Errorf("copying cover: %w", err) } } written := make([]string, 0, len(p.Tracks)) for _, t := range p.Tracks { dst := filepath.Join(tmp, t.Target) if err := copyFile(t.Source, dst); err != nil { return res, fmt.Errorf("copy %s: %w", filepath.Base(t.Source), err) } lines, err := tags.Write(t.Tags) if err != nil { return res, err } if err := audio.WriteTags(ctx, dst, lines, opts.CoverPath != ""); err != nil { return res, err } if opts.CoverPath != "" { if err := artwork.Embed(ctx, dst, opts.CoverPath); err != nil { return res, err } } written = append(written, dst) // An album is minutes of copying and encoding with nothing to show for // it otherwise, which reads as a hang. logf("copied %s", t.Target) } if err := verify(ctx, p, written, opts.VerifyDecode, opts, logf); err != nil { return res, err } finalDir, err := promote(p.ArchiveDir, tmp, opts.Collision, logf) if err != nil { return res, err } promoted = true // "both" lands the album somewhere other than the path that was asked for, // and everything downstream -- the summary, the journal, the library this // same run builds -- has to follow the album to where it actually went. res.ArchiveDir = finalDir finalPaths := make([]string, 0, len(written)) for _, f := range written { rel, err := filepath.Rel(tmp, f) if err != nil { rel = filepath.Base(f) } finalPaths = append(finalPaths, filepath.Join(finalDir, rel)) } res.Files = finalPaths if !opts.KeepStaging { // The album is archived from here on. Failing to tidy up after it is a // nuisance, not a failure: returning an error would make the caller // treat a complete archive as broken, drop its journal record, and offer // to file it all over again. for _, t := range p.Tracks { if err := os.Remove(t.Source); err != nil && !os.IsNotExist(err) { res.StagingStuck = append(res.StagingStuck, filepath.Base(t.Source)) } } // The album folder to clear is the one the scan called the album, not the // folder the first track happened to sit in: a CD1 / CD2 rip puts the // tracks one level below the album, and clearing only CD1 would leave CD2 // and the album's own sidecars sitting in staging for ever. albumDir := p.Album.Dir if albumDir == "" { albumDir = filepath.Dir(p.Tracks[0].Source) } leftovers, err := cleanStaging(albumDir, opts.StagingRoot) if err != nil { logf("note: could not fully clear %s: %v", albumDir, err) } if len(leftovers) > 0 { logf("removed non-audio leftovers from staging: %s", strings.Join(leftovers, ", ")) } res.StagingLeftovers = leftovers res.RemovedStaging = len(res.StagingStuck) == 0 if len(res.StagingStuck) > 0 { logf("note: the album is archived, but %d staged file(s) could not be removed: %s", len(res.StagingStuck), strings.Join(res.StagingStuck, ", ")) } } for _, t := range res.Files { if fi, err := os.Stat(t); err == nil { res.Bytes += fi.Size() } } return res, nil } // checkTargets refuses a plan that would write two tracks to the same name. func checkTargets(p plan.Plan) error { if len(p.Tracks) == 0 { return fmt.Errorf("plan has no tracks") } seen := map[string]string{} for _, t := range p.Tracks { name := filepath.Base(t.Target) if name == "" || name == "." || name == string(filepath.Separator) { return fmt.Errorf("plan has a track with no target name (%s)", filepath.Base(t.Source)) } if prev, ok := seen[name]; ok { return fmt.Errorf("refusing to file: %s and %s would both be written to %q, which would overwrite one of them", prev, filepath.Base(t.Source), name) } seen[name] = filepath.Base(t.Source) } return nil } func coverName(opts Options) string { if opts.CoverName != "" { return opts.CoverName } return "cover.jpg" } func verify(ctx context.Context, p plan.Plan, written []string, mode string, opts Options, logf func(string, ...any)) error { if len(written) != len(p.Tracks) { return fmt.Errorf("verification failed: wrote %d files for %d tracks", len(written), len(p.Tracks)) } for i, f := range written { src := p.Tracks[i] info, err := audio.Probe(ctx, f) if err != nil { return fmt.Errorf("verification failed reading %s: %w", filepath.Base(f), err) } if info.Codec == "" { return fmt.Errorf("verification failed: %s has no decodable audio stream", filepath.Base(f)) } if src.Ext == ".flac" && info.Codec != "flac" { return fmt.Errorf("verification failed: %s codec is %q, expected flac", filepath.Base(f), info.Codec) } if src.Duration > 0 && info.Duration > 0 { diff := info.Duration - src.Duration if diff < 0 { diff = -diff } if diff > 0.25 { return fmt.Errorf("verification failed: %s duration changed by %.3fs", filepath.Base(f), diff) } } if got, _ := tags.Get(info.Tags, tags.FieldTitle); strings.TrimSpace(got) == "" { return fmt.Errorf("verification failed: %s has no TITLE after tagging", filepath.Base(f)) } if got, _ := tags.Get(info.Tags, tags.FieldAlbumArtist); strings.TrimSpace(got) == "" { return fmt.Errorf("verification failed: %s has no ALBUMARTIST after tagging", filepath.Base(f)) } stray := tags.Dropped(info.Tags) if len(stray) > 0 { return fmt.Errorf("verification failed: %s still carries non-whitelisted tags %v", filepath.Base(f), stray) } if opts.CoverPath != "" { if info.ArtCount == 0 { return fmt.Errorf("verification failed: %s has no embedded cover", filepath.Base(f)) } } } // Every flac is integrity-tested, not only the sampled ones: a partial // download can sit anywhere in the album, and the check costs about a fifth // of a second per track. for _, f := range written { if !strings.EqualFold(filepath.Ext(f), ".flac") { continue } if err := audio.TestFLAC(ctx, f); err != nil { return fmt.Errorf("verification failed: %w", err) } } switch mode { case "all": for _, f := range written { if err := audio.DecodeOK(ctx, f); err != nil { return fmt.Errorf("verification failed: %w", err) } } case "sample": idx := []int{0, len(written) / 2, len(written) - 1} for _, i := range idx { if err := audio.DecodeOK(ctx, written[i]); err != nil { return fmt.Errorf("verification failed: %w", err) } } } logf("verified %d file(s) (decode: %s)", len(written), mode) return nil } // promote moves a fully built and verified album directory into place and // returns the directory it actually landed in. // // The return value matters: "both" deliberately puts the album somewhere other // than the path the plan named, and every later step has to agree about where // that is. func promote(target, tmp, collision string, logf func(string, ...any)) (string, error) { if _, err := os.Stat(target); err == nil { switch collision { case "replace": // Move the old album aside and only delete it once the new one is in // place. Removing it first leaves a window -- a failed rename, a // permission problem partway through RemoveAll, a power cut -- in // which the album is simply gone, with nothing to restore. stale := target + ".intake-replaced" if err := os.Rename(target, stale); err != nil { return "", fmt.Errorf("moving the existing album aside: %w", err) } if err := os.Rename(tmp, target); err != nil { // Put the old album back rather than leaving neither. if back := os.Rename(stale, target); back != nil { return "", fmt.Errorf("promote %s: %w (and the previous album is now at %s)", target, err, stale) } return "", fmt.Errorf("promote %s: %w", target, err) } if err := os.RemoveAll(stale); err != nil { logf("note: the replaced album is still at %s: %v", filepath.Base(stale), err) } logf("placed %s (replacing what was there)", target) return target, nil case "both": alt := target + " (2)" for i := 3; ; i++ { _, err := os.Stat(alt) if os.IsNotExist(err) { break } if err != nil { // Anything other than "it is not there" -- an unreadable // parent directory, say -- would otherwise spin here for // ever picking names that can never be checked. return "", fmt.Errorf("looking for a free name beside %s: %w", target, err) } if i > 1000 { return "", fmt.Errorf("could not find a free name beside %s", target) } alt = fmt.Sprintf("%s (%d)", target, i) } logf("destination exists; keeping both as %s", filepath.Base(alt)) target = alt case "merge": if err := mergeInto(tmp, target); err != nil { return "", err } logf("merged into existing %s", filepath.Base(target)) return target, nil default: return "", fmt.Errorf("destination already exists: %s", target) } } if err := os.Rename(tmp, target); err != nil { return "", fmt.Errorf("promote %s: %w", target, err) } logf("placed %s", target) return target, nil } // mergeInto moves the contents of src into dst, refusing to overwrite anything. // // Every possible conflict is found before the first file moves. Checking as it // goes left the destination holding half of one album and half of another when // the second file clashed, which is worse than either album on its own and // cannot be undone by re-running. func mergeInto(src, dst string) error { entries, err := os.ReadDir(src) if err != nil { return err } for _, e := range entries { to := filepath.Join(dst, e.Name()) if _, err := os.Stat(to); err == nil { return fmt.Errorf("merge conflict: %s already exists, so nothing was merged", to) } } for _, e := range entries { from := filepath.Join(src, e.Name()) to := filepath.Join(dst, e.Name()) if err := os.Rename(from, to); err != nil { return err } } return nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } return out.Sync() } // cleanStaging clears a filed album out of staging: its leftover files, its // now-empty subdirectories (a CD1 / CD2 rip leaves one empty disc folder behind // for every disc but the last), and then the album folder itself. // // It climbs towards stagingRoot, never past or through it. An empty staging // directory is the normal state after the last album is filed, not clutter to be // tidied away, and deleting it makes the next scan fail and the next run // recreate it. With no stagingRoot it stops at the album folder. func cleanStaging(dir, stagingRoot string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var removed []string for _, e := range entries { if e.IsDir() || audio.IsAudio(e.Name()) { continue } path := filepath.Join(dir, e.Name()) if err := os.Remove(path); err != nil { return removed, err } removed = append(removed, e.Name()) } for cur := dir; withinStaging(cur, stagingRoot); cur = filepath.Dir(cur) { pruneEmptySubdirs(cur) left, err := os.ReadDir(cur) if err != nil || len(left) > 0 { break } if err := os.Remove(cur); err != nil { break } } return removed, nil } // withinStaging reports whether dir is inside stagingRoot rather than equal to // it or above it. An empty stagingRoot means "do not climb at all". func withinStaging(dir, stagingRoot string) bool { if stagingRoot == "" || dir == "" || dir == "/" || dir == "." { return false } root, err := filepath.Abs(stagingRoot) if err != nil { return false } abs, err := filepath.Abs(dir) if err != nil { return false } rel, err := filepath.Rel(root, abs) if err != nil || rel == "." || rel == ".." { return false } return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // pruneEmptySubdirs removes directories under dir that hold nothing, deepest // first, so that a folder like CD2 or Scans does not keep an otherwise finished // album sitting in staging forever. A directory holding anything at all is left // exactly as it is. func pruneEmptySubdirs(dir string) { entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { if !e.IsDir() { continue } sub := filepath.Join(dir, e.Name()) pruneEmptySubdirs(sub) if left, err := os.ReadDir(sub); err == nil && len(left) == 0 { os.Remove(sub) } } }