package cli import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "intake/internal/artwork" "intake/internal/execute" "intake/internal/library" "intake/internal/plan" "intake/internal/scan" "intake/internal/sheet" "intake/internal/state" ) type FileResult struct { ArchiveDir string LibraryDir string Files int Bytes int64 Cover string Placeholder bool Library bool Removed bool Warnings []string } func (a *App) FilePlan(ctx context.Context, p plan.Plan, cover coverChoice, collision string) (FileResult, error) { var res FileResult res.ArchiveDir = p.ArchiveDir res.LibraryDir = p.LibraryDir // Checked first, so an unwritable journal refuses the album rather than // filing it and then discovering it cannot be recorded. A dry run changes // nothing, and that includes proving the journal writable by writing it. if !a.DryRun { if err := a.journalReady(); err != nil { return res, fmt.Errorf("refusing to file: %w", err) } } // A cover chosen out of the staging folder is deleted with the rest of the // staging leftovers by the call below, and the library is built after that, // so it would quietly be built with no artwork at all. Keep a copy first. libraryCover, dropCover := a.keepCover(p, cover) defer dropCover() out, err := execute.Run(ctx, p, execute.Options{ DryRun: a.DryRun, KeepStaging: a.Cfg.KeepStaging, VerifyDecode: a.Cfg.VerifyDecode, Collision: collision, CoverPath: cover.Path, StagingRoot: a.Cfg.Staging(), Logf: a.logf, }) if err != nil { a.forget(p) return res, err } res.Files = len(out.Files) res.Bytes = out.Bytes res.Removed = out.RemovedStaging for _, name := range out.StagingStuck { res.Warnings = append(res.Warnings, fmt.Sprintf("%s was archived but could not be removed from staging", name)) } if a.DryRun { // Nothing was written and there is nothing to record, so say what would // have happened and stop before the journal learns about an album that // is not on disk. res.Files = len(p.Tracks) res.Placeholder = cover.Placeholder res.Cover = cover.Label res.Library = !a.Cfg.NoLibrary a.logf("dry run: nothing was written") return res, nil } // "both" files the album next to the one that was already there rather than // over it, so every later step -- the summary, the journal, and the library // this same run builds -- has to use where it actually went, not where the // plan asked for. Otherwise the library is transcoded from the album that was // already there and the copy just filed is invisible to verify and audit. res.ArchiveDir = out.ArchiveDir p.ArchiveDir = out.ArchiveDir if p.LibraryDir != "" && !strings.HasSuffix(p.LibraryDir, filepath.Base(out.ArchiveDir)) { p.LibraryDir = filepath.Join(filepath.Dir(p.LibraryDir), filepath.Base(out.ArchiveDir)) } res.LibraryDir = p.LibraryDir libraryBuilt := false if !a.Cfg.NoLibrary { if err := a.buildLibrary(ctx, p, libraryCover, cover); err != nil { // Record what did land, so the archive is not left untracked, and // report both problems if that fails too. recErr := a.record(p, state.PhaseArchived, nil, cover.Placeholder, false) libraryErr := fmt.Errorf("archive is complete, but building the library copy failed: %w", err) if recErr != nil { return res, errors.Join(libraryErr, recErr) } return res, libraryErr } libraryBuilt = true } if err := a.record(p, state.PhaseArchived, nil, cover.Placeholder, libraryBuilt); err != nil { return res, err } res.Library = libraryBuilt res.Placeholder = cover.Placeholder res.Cover = cover.Label return res, nil } // keepCover returns a path to the chosen cover that will still exist once the // staging folder has been cleared, plus a function to remove the copy again. // // Only a cover that lives inside the staging album needs this: embedded art is // extracted to a temporary file by the artwork package, and a folder cover is // the real file in staging, which execute.Run deletes as a leftover. The library // copy used to be built from that deleted path, and library.Transcode skipped a // cover it could not stat without a word. func (a *App) keepCover(p plan.Plan, cover coverChoice) (string, func()) { none := func() {} if cover.Path == "" || p.Album.Dir == "" { return cover.Path, none } root, err := filepath.Abs(p.Album.Dir) if err != nil { return cover.Path, none } abs, err := filepath.Abs(cover.Path) if err != nil { return cover.Path, none } rel, err := filepath.Rel(root, abs) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return cover.Path, none } dir := "" if a.Cfg.StateDir != "" { dir = filepath.Join(a.Cfg.StateDir, "art-cache") if err := os.MkdirAll(dir, 0o755); err != nil { a.logf("warning: the library will have no cover: %v", err) return cover.Path, none } } f, err := os.CreateTemp(dir, "library-cover-*"+filepath.Ext(cover.Path)) if err != nil { a.logf("warning: the library will have no cover: %v", err) return cover.Path, none } defer f.Close() data, err := os.ReadFile(cover.Path) if err != nil { os.Remove(f.Name()) a.logf("warning: the library will have no cover: %v", err) return cover.Path, none } if _, err := f.Write(data); err != nil { os.Remove(f.Name()) a.logf("warning: the library will have no cover: %v", err) return cover.Path, none } return f.Name(), func() { os.Remove(f.Name()) } } func (a *App) buildLibrary(ctx context.Context, p plan.Plan, coverPath string, cover coverChoice) error { masters := masterFiles(p) names := make([]string, 0, len(p.Tracks)) for _, t := range p.Tracks { names = append(names, t.Target) } if len(masters) == 0 { return nil } if a.DryRun { a.logf("dry run: would transcode %d master(s) into the library", len(masters)) return nil } res, err := library.Transcode(ctx, masters, names, p.LibraryDir, library.Options{ Codec: a.Cfg.Codec, Quality: a.Cfg.Quality, ArtMaxPx: a.Cfg.LibraryArtPx, CoverPath: coverPath, VerifyDecode: true, Logf: a.logf, }) for _, w := range res.Warnings { a.logf(" warning: %s", w) } if err != nil { return err } a.logf("library: -> %s", res.Dir) return nil } func (a *App) PrepareAlbum(ctx context.Context, dir string) (plan.Plan, error) { al, err := scan.One(ctx, dir) if err != nil { return plan.Plan{}, err } if al.Status != scan.StatusReady { return plan.Plan{}, fmt.Errorf("%s: %s (%s)", al.Name, al.Status, strings.Join(al.Notes, "; ")) } names0 := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{}, a.Cfg.DiscPrefix) credit := names0.CreditFromTags if strings.TrimSpace(credit) == "" { credit = names0.AlbumArt } key, chosenCredit, err := a.resolveArtist(al, credit, names0.VariousArt, "") if err != nil { return plan.Plan{}, err } if strings.TrimSpace(chosenCredit) != "" { key.Credit = chosenCredit } p := plan.BuildWithArtist(al, a.Cfg.Archive(), a.Cfg.Library(), key, a.Cfg.DiscPrefix) a.applySavedSheet(&p) return p, nil } // applySavedSheet puts back the edits made to this album on an earlier run. // // The sheet is a complete description of what intake is about to write, so // keeping it is all that is needed to make a review resumable: `intake album // ` twice in a row does the same thing the second time, and quitting the // interface in the middle of a correction no longer throws it away. func (a *App) applySavedSheet(p *plan.Plan) { text, ok, err := a.sheetStore().Load(p.Album.Dir) if err != nil || !ok { return } before := p.Sheet() next, err := sheet.Parse(text, before) if err != nil { // A saved sheet that no longer fits -- the files changed underneath it -- // is dropped rather than left to fail on every run. a.sheetStore().Delete(p.Album.Dir) a.logf("note: the saved album sheet no longer fits this album and was discarded: %v", err) return } if err := p.ApplySheet(next); err != nil { a.sheetStore().Delete(p.Album.Dir) a.logf("note: the saved album sheet could not be applied and was discarded: %v", err) return } a.logf("applied the album sheet saved from an earlier run") } func (a *App) FileAlbum(ctx context.Context, dir string, logf func(string, ...any)) (FileResult, error) { var res FileResult if logf != nil { a.Logf = func(line string) { logf("%s", line) } } al, err := scan.One(ctx, dir) if err != nil { return res, err } if al.Status != scan.StatusReady { return res, fmt.Errorf("%s: %s (%s)", al.Name, al.Status, strings.Join(al.Notes, "; ")) } p, err := a.PrepareAlbum(ctx, dir) if err != nil { return res, err } if p.Blocked() { return res, fmt.Errorf("this album needs attention first: %s", firstHardIssue(p)) } cover := coverChoice{} if !a.Cfg.NoCover { auto := a.Yes a.Yes = true cover, err = a.chooseCover(ctx, artwork.AlbumInfo{ Artist: p.AlbumArt, Album: p.AlbumName, Year: p.Year, Tracks: len(p.Tracks), }, al.Tracks, al.Dir, nil, auto) a.Yes = auto if err != nil { return res, err } } collision := a.Collision if collision == "" { if _, statErr := os.Stat(p.ArchiveDir); statErr == nil { collision = "both" a.logf("destination already exists; keeping both copies") } } return a.FilePlan(ctx, p, cover, collision) } func firstHardIssue(p plan.Plan) string { for _, i := range p.Issues { if i.Hard { return i.Text } } return "unknown issue" } func (a *App) CoverCandidates(ctx context.Context, p plan.Plan) ([]artwork.Candidate, []string, error) { al, err := scan.One(ctx, p.Album.Dir) if err != nil { return nil, nil, err } return artwork.Discover(ctx, artwork.AlbumInfo{ Artist: p.AlbumArt, Album: p.AlbumName, Year: p.Year, Tracks: len(p.Tracks), }, al.Tracks, artwork.Options{ StagingDir: al.Dir, LocalCoverFolder: a.Cfg.CoverFolder, Offline: a.Cfg.Offline, CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"), }) } func (a *App) FileAlbumWithCover(ctx context.Context, dir, coverPath string, placeholder bool, logf func(string, ...any)) (FileResult, error) { var res FileResult if logf != nil { a.Logf = func(line string) { logf("%s", line) } } p, err := a.PrepareAlbum(ctx, dir) if err != nil { return res, err } if p.Blocked() { return res, fmt.Errorf("this album needs attention first: %s", firstHardIssue(p)) } cover := coverChoice{} if coverPath != "" { cover = coverChoice{Path: coverPath, Placeholder: placeholder} if placeholder { cover.Source = artwork.SourcePlaceholder cover.Label = "placeholder" } else { cover.Source = artwork.SourceLocalFolder cover.Label = filepath.Base(coverPath) } } collision := a.Collision if collision == "" { if _, statErr := os.Stat(p.ArchiveDir); statErr == nil { collision = "both" a.logf("destination already exists; keeping both copies") } } return a.FilePlan(ctx, p, cover, collision) }