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
// <name>` 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)
}