git/intake/main
internal/cli/file.go
365 lines11 KB last change raw · history
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)
}