package cli
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"intake/internal/artist"
"intake/internal/artwork"
"intake/internal/audio"
"intake/internal/config"
"intake/internal/library"
"intake/internal/plan"
"intake/internal/scan"
"intake/internal/sheet"
"intake/internal/state"
"intake/internal/tags"
)
type App struct {
Cfg config.Config
Out io.Writer
Err io.Writer
In io.Reader
DryRun bool
Yes bool
JSON bool
EditNames bool
FastScan bool
ImagePreview bool
Logf func(string)
Quiet bool
// Collision overrides what happens when the destination already exists:
// "replace", "merge", "both" or "" to work it out.
Collision string
// Fix makes commands that report a problem repair it where they can.
Fix bool
}
func New(cfg config.Config, out, errw io.Writer, in io.Reader) *App {
return &App{Cfg: cfg, Out: out, Err: errw, In: in}
}
func (a *App) logf(format string, args ...any) {
if a.Quiet {
return
}
if a.Logf != nil {
a.Logf(strings.TrimRight(fmt.Sprintf(format, args...), "\n"))
return
}
fmt.Fprintf(a.Out, format+"\n", args...)
}
func (a *App) Scan(ctx context.Context) error {
albums, err := scan.StagingWith(ctx, a.Cfg.Staging(), scan.Options{Probe: !a.FastScan})
if err != nil {
return err
}
if a.JSON {
type row struct {
Dir string `json:"dir"`
Name string `json:"name"`
Status string `json:"status"`
Tracks int `json:"tracks"`
Minutes string `json:"minutes"`
Notes []string `json:"notes,omitempty"`
}
var rows []row
for _, al := range albums {
rows = append(rows, row{
Dir: al.Dir, Name: al.Name, Status: string(al.Status),
Tracks: len(al.Tracks), Minutes: fmt.Sprintf("%.1f", al.TotalDur/60), Notes: al.Notes,
})
}
enc := json.NewEncoder(a.Out)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{"staging": a.Cfg.Staging(), "albums": rows})
}
a.logf("staging: %s", a.Cfg.Staging())
if len(albums) == 0 {
a.logf("no albums found")
return nil
}
a.logf("%-30s %-11s %6s %9s %s", "ALBUM", "STATUS", "TRACKS", "LENGTH", "NOTES")
for _, al := range albums {
note := strings.Join(al.Notes, "; ")
if al.Subdirs > 0 && note == "" {
note = fmt.Sprintf("%d subdirector(ies)", al.Subdirs)
}
tracks := len(al.Tracks)
if tracks == 0 {
tracks = al.FileCount
}
length := mmss(al.TotalDur)
if al.TotalDur == 0 && al.FileCount > 0 {
length = "--:--"
}
a.logf("%-30s %-11s %6d %9s %s",
truncate(al.Name, 30), al.Status, tracks, length, note)
}
return nil
}
// ignore loads the list of staging folders to leave alone. A missing file is not
// an error: not having ignored anything yet is the normal state.
func (a *App) ignore() map[string]bool {
set, err := a.Cfg.LoadIgnore()
if err != nil {
a.logf("warning: could not read %s: %v", a.Cfg.IgnorePath(), err)
return map[string]bool{}
}
return set
}
func (a *App) listStaging(ctx context.Context) ([]scan.Album, error) {
if !audio.HasTool("ffprobe") || !audio.HasTool("metaflac") {
return nil, errors.New("intake needs ffprobe and metaflac on PATH")
}
staging := a.Cfg.Staging()
if _, err := os.Stat(staging); err != nil {
return nil, fmt.Errorf("staging directory %s is not readable: %w", staging, err)
}
return scan.StagingWith(ctx, staging, scan.Options{Probe: false, Ignore: a.ignore()})
}
func findAlbum(albums []scan.Album, name string) *scan.Album {
for i := range albums {
if albums[i].Name == name {
return &albums[i]
}
}
var hits []*scan.Album
for i := range albums {
if strings.Contains(strings.ToLower(albums[i].Name), strings.ToLower(name)) {
hits = append(hits, &albums[i])
}
}
if len(hits) == 1 {
return hits[0]
}
return nil
}
func (a *App) probeAlbum(ctx context.Context, dir string) (scan.Album, error) {
a.logf("reading %s ...", filepath.Base(dir))
return scan.One(ctx, dir)
}
func (a *App) Album(ctx context.Context, name string) error {
albums, err := a.listStaging(ctx)
if err != nil {
return err
}
found := findAlbum(albums, name)
if found == nil {
return fmt.Errorf("no album matching %q in %s", name, a.Cfg.Staging())
}
full, err := a.probeAlbum(ctx, found.Dir)
if err != nil {
return err
}
return a.process(ctx, full)
}
func (a *App) Resume(ctx context.Context) error {
j, err := a.journal()
if err != nil {
return err
}
albums, err := a.listStaging(ctx)
if err != nil {
return err
}
// An album that cannot be filed without a decision must not be allowed to
// block the ones behind it. Resume used to stop at the first ready album
// whatever state it was in, so one mistagged mp3 meant no later album was
// ever reached -- and because process reported the refusal as success, the
// command exited zero and looked like it had simply finished.
var blocked []string
for _, al := range albums {
if al.Status != scan.StatusReady {
continue
}
if rec, ok := j.Get(al.Dir); ok && rec.Phase == state.PhaseArchived {
continue
}
full, err := a.probeAlbum(ctx, al.Dir)
if err != nil {
return err
}
if reason := a.blockedReason(full); reason != "" {
blocked = append(blocked, fmt.Sprintf("%s: %s", full.Name, reason))
continue
}
a.logf("resuming %s", al.Name)
return a.process(ctx, full)
}
if len(blocked) > 0 {
a.logf("")
a.logf("nothing could be resumed: %d ready album(s) need attention first", len(blocked))
for _, b := range blocked {
a.logf(" ! %s", b)
}
return errors.New("every ready album needs attention first")
}
a.logf("nothing to resume: every ready album is archived or absent")
return nil
}
// blockedReason says why an album cannot be filed without a decision, using only
// what can be worked out from the tags. It deliberately does not resolve the
// artist, because that can ask a question, and this runs before anybody has
// agreed to be asked.
func (a *App) blockedReason(al scan.Album) string {
p := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{}, a.Cfg.DiscPrefix)
for _, i := range p.Issues {
if i.Hard {
return i.Text
}
}
return ""
}
func (a *App) process(ctx context.Context, al scan.Album) error {
if al.Status != scan.StatusReady {
return fmt.Errorf("%s: %s (%s)", al.Name, al.Status, strings.Join(al.Notes, "; "))
}
p0 := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{}, a.Cfg.DiscPrefix)
needsNames := sheetFixable(p0) || a.EditNames
if a.Yes && needsNames && p0.Blocked() {
// --yes is for unattended runs. The editor would sit waiting for a
// person who is not watching, which hangs the whole batch on the first
// album whose names cannot be worked out, so refuse and say what is
// wrong instead. Every case that reaches here is a hard issue.
a.logf("")
a.logf("REFUSING to proceed: %d issue(s) need a name, and --yes cannot open an editor to supply one.", countHard(p0))
for _, i := range p0.Issues {
if i.Hard {
a.logf(" ! %s", i.Text)
}
}
return errors.New("names need editing, which --yes cannot do")
}
var edited *sheet.Sheet
var changes sheet.Changes
if needsNames {
before := p0.Sheet()
got, err := a.editSheet(&p0)
if err != nil {
if a.Yes && p0.Blocked() {
a.logf("")
a.logf("REFUSING to proceed: %v", err)
return errors.New("hard issues present; refusing --yes")
}
return err
}
if got != nil {
edited, changes = got, got.ChangedFrom(before)
a.logf("")
a.logf("ALBUM SHEET")
a.logf(" album : %s", p0.AlbumName)
a.logf(" album artist : %s", p0.AlbumArt)
a.logf(" artist folder: %s", p0.ArtistDir)
a.logf(" album folder : %s", p0.AlbumDir)
a.logf(" year : %s", orUnknown(p0.Year))
a.logf(" tracks : %d", len(p0.Tracks))
}
}
credit := p0.CreditFromTags
if strings.TrimSpace(credit) == "" {
credit = p0.AlbumArt
}
editorArtist := ""
if changes.ArtistDir {
editorArtist = p0.ArtistDir
}
key, chosenCredit, err := a.resolveArtist(al, credit, p0.VariousArt, editorArtist)
if err != nil {
return err
}
if strings.TrimSpace(chosenCredit) != "" {
key.Credit = chosenCredit
}
p := plan.BuildWithArtist(al, a.Cfg.Archive(), a.Cfg.Library(), key, a.Cfg.DiscPrefix)
if edited != nil {
// Applied after the artist is resolved, because the sheet is the last
// word on what gets written.
if err := p.ApplySheet(*edited); err != nil {
return err
}
}
cover := coverChoice{}
if !a.Cfg.NoCover {
if !a.DryRun {
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, a.Yes)
if err != nil {
return err
}
} else {
a.logf("")
a.logf("dry run: would gather cover art and let you choose")
}
}
aliasCredit := key.Credit
if strings.TrimSpace(p0.CreditFromTags) != "" {
aliasCredit = p0.CreditFromTags
}
if (key.Source == "entered" || key.Source == "editor") &&
strings.TrimSpace(aliasCredit) != "" && key.Dir != aliasCredit {
if ix, err := a.artistIndex(aliasCredit); err == nil {
a.rememberAlias(ix, aliasCredit, key.Dir)
}
}
a.render(p)
if p.Blocked() {
a.logf("")
a.logf("REFUSING to proceed: %d issue(s) must be resolved first.", countHard(p))
if a.Yes {
return NeedsAPerson{Reason: firstHardIssue(p)}
}
// Not a success: a caller that cannot see the output has to be able to
// tell that the album was left alone, and batch has to know to park it
// rather than count it as a failure.
return NeedsAPerson{Reason: firstHardIssue(p)}
}
if a.DryRun {
a.logf("")
a.logf("dry run: no changes made")
return nil
}
if !a.Yes {
ok, err := a.confirm("proceed?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
collision := ""
if _, err := os.Stat(p.ArchiveDir); err == nil {
a.logf("")
a.logf("destination already exists: %s", p.ArchiveDir)
choice, err := a.promptCollision()
if err != nil {
return err
}
if choice == "skip" {
a.logf("skipped; nothing changed")
return nil
}
collision = choice
}
res, err := a.FilePlan(ctx, p, cover, collision)
if err != nil {
return err
}
a.logf("")
a.logf("done: %d file(s) -> %s (%s)", res.Files, res.ArchiveDir, humanBytes(res.Bytes))
switch {
case res.Placeholder:
a.logf("cover: PLACEHOLDER - replace it later with 'intake library re-cover'")
case res.Cover != "":
a.logf("cover: %s", res.Cover)
default:
a.logf("cover: none")
}
if res.Removed {
a.logf("staged originals removed")
}
return nil
}
func masterFiles(p plan.Plan) []string {
out := make([]string, 0, len(p.Tracks))
for _, t := range p.Tracks {
out = append(out, filepath.Join(p.ArchiveDir, t.Target))
}
return out
}
func (a *App) Transcode(ctx context.Context, target string) error {
j, err := a.journal()
if err != nil {
return err
}
rec, ok := findRecord(j, target)
if !ok {
return fmt.Errorf("no archived album matching %q in the journal", target)
}
if _, err := os.Stat(rec.ArchiveDir); err != nil {
return fmt.Errorf("album directory is missing: %s", rec.ArchiveDir)
}
masters, _, err := archiveMasters(rec.ArchiveDir)
if err != nil {
return err
}
if len(masters) == 0 {
return fmt.Errorf("no audio files found in %s", rec.ArchiveDir)
}
a.logf("album : %s - %s", rec.AlbumArtist, rec.Album)
a.logf("masters : %d in %s", len(masters), rec.ArchiveDir)
a.logf("library : %s", rec.LibraryDir)
a.logf("codec : %s (quality %d)", a.Cfg.Codec, a.Cfg.Quality)
if !a.Yes {
ok, err := a.confirm("rebuild the library copy?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
res, err := a.rebuildLibrary(ctx, rec)
if err != nil {
return err
}
a.logf("")
a.logf("done: %d file(s) -> %s (%s)", len(res.Files), res.Dir, humanBytes(res.Bytes))
return nil
}
// fixLibrary rebuilds an album's library copy from its masters, when the audit
// was asked to repair what it finds. It reports whether it succeeded, and never
// touches the archive: the masters are the one thing a rebuild cannot replace.
func (a *App) fixLibrary(ctx context.Context, rec state.Record, why string) int {
if !a.Fix || a.DryRun {
if a.Fix && a.DryRun {
a.logf("dry run: would rebuild the library copy of %s (%s)", rec.Album, why)
}
return 0
}
a.logf("rebuilding %s: %s", rec.Album, why)
if _, err := a.rebuildLibrary(ctx, rec); err != nil {
a.logf(" could not rebuild it: %v", err)
return 0
}
return 1
}
// fixLibraryCover puts a cover.jpg back into a library copy that has lost one,
// without re-encoding the audio: the archive still has the cover it was built
// from, so this is a copy rather than a rebuild.
func (a *App) fixLibraryCover(ctx context.Context, rec state.Record) bool {
src := filepath.Join(rec.ArchiveDir, "cover.jpg")
if _, err := os.Stat(src); err != nil {
return false
}
if a.DryRun {
a.logf("dry run: would copy the cover into %s", rec.LibraryDir)
return true
}
if err := os.MkdirAll(rec.LibraryDir, 0o755); err != nil {
return false
}
dst := filepath.Join(rec.LibraryDir, "cover.jpg")
if err := copyCoverFile(src, dst); err != nil {
a.logf(" could not copy the cover: %v", err)
return false
}
a.logf("restored the cover of %s", rec.Album)
return true
}
type auditFinding struct {
Album string `json:"album"`
Dir string `json:"dir"`
Problem string `json:"problem"`
}
func (a *App) Audit(ctx context.Context) error {
j, err := a.journal()
if err != nil {
return err
}
keys := make([]string, 0, len(j.Albums))
for k := range j.Albums {
keys = append(keys, k)
}
sort.Strings(keys)
// The extension follows the configured codec. Hard-coding ".ogg" meant every
// album in an opus library was reported INCOMPLETE, every time, with a
// suggestion to rebuild it -- which rebuilds it identically.
ext := library.Ext(a.Cfg.Codec)
dirs, err := a.archiveDirs()
if err != nil {
return err
}
orphans := a.unrecorded(j, dirs)
var checked, ok, problems, missingArchives, fixed int
var findings []auditFinding
for _, dir := range orphans {
problems++
findings = append(findings, auditFinding{"(not in the journal)", dir, "this album is in the archive but no journal record names it"})
}
for _, k := range keys {
rec := j.Albums[k]
if rec.Phase != state.PhaseArchived {
continue
}
checked++
label := rec.AlbumArtist + " - " + rec.Album
// A record whose archive is gone or holds no audio is the worst thing to
// be quiet about: this command exists to notice exactly that, and it used
// to skip it and then report "0 needing attention".
masters, _, err := archiveMasters(rec.ArchiveDir)
if err != nil || len(masters) == 0 {
problems++
missingArchives++
why := "the archive directory holds no audio"
if err != nil {
why = "the archive directory cannot be read: " + err.Error()
}
findings = append(findings, auditFinding{label, rec.ArchiveDir, "no archive: " + why})
continue
}
if a.Fix {
a.logf("checking %s ...", label)
}
if _, err := os.Stat(rec.LibraryDir); err != nil {
problems++
findings = append(findings, auditFinding{label, rec.LibraryDir, "no library copy"})
fixed += a.fixLibrary(ctx, rec, "there was no library copy")
continue
}
missing := 0
for _, m := range masters {
want := filepath.Join(rec.LibraryDir, strings.TrimSuffix(filepath.Base(m), filepath.Ext(m))+ext)
if _, err := os.Stat(want); err != nil {
missing++
}
}
coverMissing := false
if _, err := os.Stat(filepath.Join(rec.LibraryDir, "cover.jpg")); err != nil {
coverMissing = true
}
switch {
case missing > 0:
problems++
findings = append(findings, auditFinding{label, rec.LibraryDir,
fmt.Sprintf("%d of %d tracks missing from the library copy", missing, len(masters))})
fixed += a.fixLibrary(ctx, rec, fmt.Sprintf("%d of %d tracks were missing", missing, len(masters)))
case coverMissing:
problems++
findings = append(findings, auditFinding{label, rec.LibraryDir, "the library copy has no cover.jpg"})
if !a.fixLibraryCover(ctx, rec) {
a.logf("could not add a cover to %s; the archive has no cover.jpg either, so use `library re-cover`", rec.Album)
} else {
fixed++
}
default:
ok++
}
}
// Everything a person reads is printed only when there is no JSON to print,
// so a caller that asked for JSON gets JSON and nothing else. It used to
// print the problems first, which made the output unparseable exactly when it
// had something to say.
if a.JSON {
enc := json.NewEncoder(a.Out)
enc.SetIndent("", " ")
out := map[string]any{
"checked": checked, "ok": ok, "problems": problems,
"missing_archives": missingArchives,
"unrecorded": len(orphans),
"fixed": fixed,
}
if findings != nil {
out["albums"] = findings
}
return enc.Encode(out)
}
for _, f := range findings {
a.logf("%s", f.Album)
a.logf(" %s", f.Dir)
a.logf(" %s", f.Problem)
}
if len(findings) > 0 {
a.logf("")
}
a.logf("library audit: %d album(s) checked, %d complete, %d needing attention", checked, ok, problems)
if fixed > 0 {
a.logf("%d library copy(ies) were rebuilt.", fixed)
}
if problems > 0 && !a.Fix {
a.logf("Run it again with --fix to rebuild the library copies that are missing or short.")
}
if missingArchives > 0 {
a.logf("%d archived album(s) have no audio in their archive directory.", missingArchives)
}
if len(orphans) > 0 {
a.logf("%d album(s) are in the archive with no journal record; they are invisible to verify and can be filed again.", len(orphans))
}
if problems > 0 {
a.logf("Rebuild one with: intake library transcode <album>")
}
return nil
}
func archiveMasters(dir string) ([]string, []string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, err
}
var masters, names []string
for _, e := range entries {
if e.IsDir() || !audio.IsAudio(e.Name()) {
continue
}
masters = append(masters, filepath.Join(dir, e.Name()))
names = append(names, e.Name())
}
sort.Strings(masters)
sort.Strings(names)
return masters, names, nil
}
// archiveDirs lists the album directories actually present in the archive, two
// levels down.
//
// The journal is the only thing that knows an album exists, so an album that
// reached the archive without a record -- the process was killed between the
// promotion and the journal write, or somebody copied it in by hand -- is
// invisible: verify says nothing about it and a later run happily files a second
// copy beside it. Comparing the two lists is what makes that visible.
func (a *App) archiveDirs() ([]string, error) {
root := a.Cfg.Archive()
artists, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, artist := range artists {
if !artist.IsDir() || strings.HasPrefix(artist.Name(), ".") {
continue
}
albums, err := os.ReadDir(filepath.Join(root, artist.Name()))
if err != nil {
continue
}
for _, album := range albums {
if !album.IsDir() || strings.HasPrefix(album.Name(), ".") {
continue
}
out = append(out, filepath.Join(root, artist.Name(), album.Name()))
}
}
sort.Strings(out)
return out, nil
}
// unrecorded returns the archive directories the journal does not name.
func (a *App) unrecorded(j *state.Journal, dirs []string) []string {
known := map[string]bool{}
for _, rec := range j.Albums {
if rec.ArchiveDir != "" {
known[filepath.Clean(rec.ArchiveDir)] = true
}
}
var out []string
for _, dir := range dirs {
if !known[filepath.Clean(dir)] {
out = append(out, dir)
}
}
return out
}
func (a *App) journalPath() string { return filepath.Join(a.Cfg.StateDir, "state.json") }
func (a *App) journal() (*state.Journal, error) {
path := a.journalPath()
j, err := state.Open(path)
if err != nil {
return nil, fmt.Errorf("reading journal %s: %w", path, err)
}
return j, nil
}
func (a *App) record(p plan.Plan, phase state.Phase, issues []string, placeholder, libraryBuilt bool) error {
err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
// Read the previous record before writing the new one. Asking afterwards
// always sees the value that was just written, so the check that was meant
// to keep a built library marked as built never ran, and every later write
// downgraded it to false -- after which verify reported an album whose
// library copy is on disk as "without a library copy".
if prev, ok := j.Get(p.Album.Dir); ok && prev.LibraryBuilt && !libraryBuilt {
libraryBuilt = true
}
j.Put(state.Record{
Source: p.Album.Dir,
AlbumArtist: p.AlbumArt,
Album: p.AlbumName,
Phase: phase,
ArchiveDir: p.ArchiveDir,
LibraryDir: p.LibraryDir,
Tracks: len(p.Tracks),
Issues: issues,
Placeholder: placeholder,
LibraryBuilt: libraryBuilt,
})
return true, nil
})
if err != nil {
return fmt.Errorf("recording %q in the journal: %w", p.AlbumName, err)
}
return nil
}
// journalReady proves the journal can be written before anything is filed.
//
// An album that files and then cannot be recorded is worse than one that never
// files: it sits in the archive untracked, so library verify and library audit
// skip past it and a later run offers to file it a second time. Writing the
// journal unchanged is the cheapest way to exercise the exact write that will
// matter later, and it happens before the first file is copied.
func (a *App) journalReady() error {
// The lock is taken as well as the write: proving the journal writable only
// helps if the same lock will still be available when the real record is
// written a minute later.
err := state.Update(a.journalPath(), func(*state.Journal) (bool, error) { return true, nil })
if err != nil {
return fmt.Errorf("journal at %s cannot be written: %w", a.journalPath(), err)
}
return nil
}
func (a *App) forget(p plan.Plan) {
err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
rec, ok := j.Get(p.Album.Dir)
if !ok {
return false, nil
}
// A run that failed proves nothing about an album recorded earlier.
// forget used to be called on every failure -- including one that happened
// before a single byte was copied, when the album it is about is still
// sitting safely in the archive -- and deleting its record left the archive
// untracked, invisible to verify and audit, and free to be filed a second
// time. The record only goes when the album it points at has gone too.
if rec.ArchiveDir != "" {
if _, err := os.Stat(rec.ArchiveDir); err == nil {
return false, nil
}
}
j.Delete(p.Album.Dir)
return true, nil
})
if err != nil {
fmt.Fprintf(a.Err, "warning: could not update journal: %v\n", err)
}
}
func (a *App) Verify(ctx context.Context) error {
j, err := a.journal()
if err != nil {
return err
}
if len(j.Albums) == 0 {
a.logf("journal is empty: nothing recorded yet (%s)", j.Path())
return nil
}
keys := make([]string, 0, len(j.Albums))
for k := range j.Albums {
keys = append(keys, k)
}
sort.Strings(keys)
dirs, err := a.archiveDirs()
if err != nil {
return err
}
orphans := a.unrecorded(j, dirs)
var archived, missing, placeholders, noLibrary int
var placeholderRows []state.Record
var missingRows []string
for _, k := range keys {
rec := j.Albums[k]
if rec.Phase != state.PhaseArchived {
continue
}
archived++
if _, err := os.Stat(rec.ArchiveDir); err != nil {
missing++
missingRows = append(missingRows, rec.ArchiveDir)
}
if !rec.LibraryBuilt && !a.Cfg.NoLibrary {
noLibrary++
}
if rec.Placeholder {
placeholders++
placeholderRows = append(placeholderRows, rec)
}
}
// JSON mode prints JSON and nothing else. The human-readable report used to
// come out first, which left a script with "MISSING ...{...}" -- unparseable
// precisely when there was something wrong to report.
if a.JSON {
type row struct {
Path string `json:"archive_dir"`
Artist string `json:"album_artist"`
Album string `json:"album"`
Placeholder bool `json:"placeholder"`
Library bool `json:"library_built"`
Missing bool `json:"archive_missing"`
}
rows := []row{}
for _, k := range keys {
rec := j.Albums[k]
if rec.Phase != state.PhaseArchived {
continue
}
_, statErr := os.Stat(rec.ArchiveDir)
rows = append(rows, row{rec.ArchiveDir, rec.AlbumArtist, rec.Album, rec.Placeholder, rec.LibraryBuilt, statErr != nil})
}
enc := json.NewEncoder(a.Out)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"albums": rows,
"archived": archived,
"missing": missing,
"placeholders": placeholders,
"no_library": noLibrary,
"unrecorded": orphans,
})
}
for _, dir := range missingRows {
a.logf("MISSING %s (journaled but not on disk)", dir)
}
for _, dir := range orphans {
a.logf("UNRECORDED %s (in the archive but no journal record names it)", dir)
}
if len(placeholderRows) > 0 {
a.logf("")
a.logf("ALBUMS WITH PLACEHOLDER ART (%d) -- these need real covers", placeholders)
for _, rec := range placeholderRows {
name := rec.AlbumArtist
if rec.Album != "" {
name += " - " + rec.Album
}
if _, err := os.Stat(rec.ArchiveDir); err != nil {
a.logf(" %-40s %s (directory missing!)", truncate(name, 40), rec.ArchiveDir)
continue
}
a.logf(" %-40s %s", truncate(name, 40), rec.ArchiveDir)
}
a.logf("")
a.logf("Replace one with: intake library re-cover <path>")
}
a.logf("")
a.logf("journal: %d archived, %d missing on disk, %d with placeholder covers, %d without a library copy, %d unrecorded in the archive",
archived, missing, placeholders, noLibrary, len(orphans))
if noLibrary > 0 {
a.logf("Build one with: intake library transcode <album>")
}
return nil
}
// JournalEntry is one record as the interface shows it.
type JournalEntry struct {
Source string
AlbumArtist string
Album string
Phase string
ArchiveDir string
LibraryDir string
Tracks int
Placeholder bool
Library bool
Missing bool
Unrecorded bool
// NeedsAttention is an album an unattended run parked.
NeedsAttention bool
Reason string
}
// JournalEntries lists every record, newest name first, with what is actually on
// disk beside it.
func (a *App) JournalEntries(ctx context.Context) ([]JournalEntry, error) {
j, err := a.journal()
if err != nil {
return nil, err
}
out := make([]JournalEntry, 0, len(j.Albums))
for _, rec := range j.Albums {
e := JournalEntry{
Source: rec.Source,
AlbumArtist: rec.AlbumArtist,
Album: rec.Album,
Phase: string(rec.Phase),
ArchiveDir: rec.ArchiveDir,
LibraryDir: rec.LibraryDir,
Tracks: rec.Tracks,
Placeholder: rec.Placeholder,
Library: rec.LibraryBuilt,
}
e.NeedsAttention = rec.Phase == state.PhaseNeedsAttention
if len(rec.Issues) > 0 {
e.Reason = rec.Issues[0]
}
if rec.ArchiveDir != "" {
if _, err := os.Stat(rec.ArchiveDir); err != nil {
e.Missing = true
}
}
out = append(out, e)
}
dirs, err := a.archiveDirs()
if err != nil {
return nil, err
}
for _, dir := range a.unrecorded(j, dirs) {
out = append(out, JournalEntry{
Album: filepath.Base(dir),
ArchiveDir: dir,
Phase: "unrecorded",
Unrecorded: true,
})
}
sort.Slice(out, func(i, k int) bool {
if out[i].AlbumArtist != out[k].AlbumArtist {
return out[i].AlbumArtist < out[k].AlbumArtist
}
return out[i].Album < out[k].Album
})
return out, nil
}
// ForgetSource drops the record for a staging source, without a prompt.
func (a *App) ForgetSource(source string) error {
if a.DryRun {
return nil
}
return state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
if _, ok := j.Get(source); !ok {
return false, fmt.Errorf("no journal record for %s", source)
}
j.Delete(source)
return true, nil
})
}
// UnfileSource moves a recorded album back to staging and drops its record.
func (a *App) UnfileSource(source string, progress func(string)) error {
if progress == nil {
progress = func(string) {}
}
j, err := a.journal()
if err != nil {
return err
}
rec, ok := j.Get(source)
if !ok {
return fmt.Errorf("no journal record for %s", source)
}
if rec.ArchiveDir == "" {
return fmt.Errorf("the record for %q has no archive directory", rec.Album)
}
if _, err := os.Stat(rec.ArchiveDir); err != nil {
return fmt.Errorf("the album is not in the archive at %s: %w", rec.ArchiveDir, err)
}
if a.DryRun {
progress("dry run: would move " + rec.ArchiveDir + " back to staging")
return nil
}
dest := filepath.Join(a.Cfg.Staging(), filepath.Base(rec.ArchiveDir))
if _, err := os.Stat(dest); err == nil {
dest = freePath(dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.Rename(rec.ArchiveDir, dest); err != nil {
return fmt.Errorf("moving the album back to staging: %w", err)
}
progress("moved the masters back to " + dest)
if rec.LibraryDir != "" {
if err := os.RemoveAll(rec.LibraryDir); err != nil {
progress("could not remove the library copy: " + err.Error())
} else {
progress("removed the library copy")
}
}
return state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
j.Delete(source)
return true, nil
})
}
// ForgetRecord drops a journal record and leaves every file exactly where it is.
func (a *App) ForgetRecord(ctx context.Context, target string) error {
j, err := a.journal()
if err != nil {
return err
}
rec, ok := findRecord(j, target)
if !ok {
return fmt.Errorf("no archived album matching %q in the journal", target)
}
a.logf("album : %s - %s", rec.AlbumArtist, rec.Album)
a.logf("archive: %s", rec.ArchiveDir)
if rec.LibraryDir != "" {
a.logf("library: %s", rec.LibraryDir)
}
a.logf("")
a.logf("The record goes; every file stays exactly where it is. intake will no")
a.logf("longer know about this album: library verify and audit will stop counting")
a.logf("it, and if its staging folder ever comes back it will be filed again.")
if !a.Yes {
ok, err := a.confirm("forget this album?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
if a.DryRun {
a.logf("dry run: would forget %s", rec.Album)
return nil
}
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
j.Delete(rec.Source)
return true, nil
}); err != nil {
return fmt.Errorf("forgetting %q: %w", rec.Album, err)
}
a.logf("forgotten: %s", rec.Album)
return nil
}
// Unfile puts an album back in staging and drops its record, for the mistake that
// is only noticed later.
//
// The masters are moved, never copied: after filing, the archive holds the only
// copy of that audio -- the staged originals were deleted once the archive
// verified -- so a copy followed by a delete would risk losing it. The library
// copy is derived from the masters and is removed, because it would otherwise sit
// there describing an album that is no longer archived.
func (a *App) Unfile(ctx context.Context, target string) error {
j, err := a.journal()
if err != nil {
return err
}
rec, ok := findRecord(j, target)
if !ok {
return fmt.Errorf("no archived album matching %q in the journal", target)
}
if _, err := os.Stat(rec.ArchiveDir); err != nil {
return fmt.Errorf("the album is not in the archive at %s: %w", rec.ArchiveDir, err)
}
dest := filepath.Join(a.Cfg.Staging(), filepath.Base(rec.ArchiveDir))
if _, err := os.Stat(dest); err == nil {
dest = freePath(dest)
}
a.logf("album : %s - %s", rec.AlbumArtist, rec.Album)
a.logf("archive: %s", rec.ArchiveDir)
a.logf("staging: %s", dest)
if rec.LibraryDir != "" {
a.logf("the library copy at %s is a transcode of these masters and will be removed", rec.LibraryDir)
}
a.logf("")
a.logf("The masters move back to staging. Nothing is copied, so nothing is lost")
a.logf("if this is interrupted; the album is simply in one place or the other.")
if !a.Yes {
ok, err := a.confirm("move it back to staging?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
if a.DryRun {
a.logf("dry run: would move %s to %s", rec.ArchiveDir, dest)
return nil
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.Rename(rec.ArchiveDir, dest); err != nil {
return fmt.Errorf("moving the album back to staging: %w", err)
}
if rec.LibraryDir != "" {
if err := os.RemoveAll(rec.LibraryDir); err != nil {
return fmt.Errorf("the album is back in staging, but its library copy could not be removed: %w", err)
}
}
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
j.Delete(rec.Source)
return true, nil
}); err != nil {
return fmt.Errorf("the album is back in staging, but its record could not be removed: %w", err)
}
a.logf("")
a.logf("moved %s back to staging", rec.Album)
return nil
}
// freePath finds a name beside path that is not taken.
func freePath(path string) string {
for i := 2; i < 1000; i++ {
candidate := fmt.Sprintf("%s (%d)", path, i)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return path + " (copy)"
}
func (a *App) ReCover(ctx context.Context, target string) error {
j, err := a.journal()
if err != nil {
return err
}
rec, ok := findRecord(j, target)
if !ok {
return fmt.Errorf("no archived album matching %q in the journal", target)
}
if _, err := os.Stat(rec.ArchiveDir); err != nil {
return fmt.Errorf("album directory is missing: %s", rec.ArchiveDir)
}
a.logf("album : %s", filepath.Join(rec.AlbumArtist, rec.Album))
a.logf("folder: %s", rec.ArchiveDir)
if rec.Placeholder {
a.logf("status: currently using placeholder art")
}
var tracks []audio.Track
entries, err := os.ReadDir(rec.ArchiveDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() || !audio.IsAudio(e.Name()) {
continue
}
t, err := audio.Probe(ctx, filepath.Join(rec.ArchiveDir, e.Name()))
if err != nil {
continue
}
tracks = append(tracks, t)
}
if len(tracks) == 0 {
return fmt.Errorf("no audio files found in %s", rec.ArchiveDir)
}
year := ""
if len(tracks) > 0 {
if v, ok := tags.Get(tracks[0].Tags, tags.FieldDate); ok {
year = v
}
}
var exclude []string
if h, err := artwork.Hash(filepath.Join(rec.ArchiveDir, "cover.jpg")); err == nil {
exclude = append(exclude, h)
}
choice, err := a.chooseCover(ctx, artwork.AlbumInfo{
Artist: rec.AlbumArtist,
Album: rec.Album,
Year: year,
Tracks: len(tracks),
}, tracks, rec.ArchiveDir, exclude, false)
if err != nil {
return err
}
if choice.Path == "" {
return fmt.Errorf("no cover chosen; nothing changed")
}
if err := a.applyCover(ctx, rec, choice, tracks); err != nil {
return err
}
a.logf("")
a.logf("cover written to %s and embedded in %d file(s)", filepath.Join(rec.ArchiveDir, "cover.jpg"), len(tracks))
if choice.Placeholder {
a.logf("note: this is still a placeholder")
} else {
a.logf("this album no longer appears in the placeholder list")
}
return nil
}
// applyCover writes a new cover into an archived album, embeds it in the
// masters, records the change, and rebuilds the library copy if there is one.
//
// The library has its own cover.jpg and its own embedded picture, so replacing
// the archive's art and stopping there leaves the two disagreeing: the
// placeholder list stops mentioning the album while the copy people actually
// stream still carries the placeholder.
func (a *App) applyCover(ctx context.Context, rec state.Record, choice coverChoice, tracks []audio.Track) error {
dest := filepath.Join(rec.ArchiveDir, "cover.jpg")
if err := copyCoverFile(choice.Path, dest); err != nil {
return err
}
for _, t := range tracks {
if err := artwork.Embed(ctx, t.Path, dest); err != nil {
return err
}
}
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
rec.Placeholder = choice.Placeholder
j.Put(rec)
return true, nil
}); err != nil {
return fmt.Errorf("recording the new cover for %q: %w", rec.Album, err)
}
if !a.Cfg.NoLibrary && rec.LibraryBuilt && rec.LibraryDir != "" {
_, err := a.rebuildLibrary(ctx, rec)
return err
}
return nil
}
// archiveTracks probes the masters of an album that is already in the archive.
func (a *App) archiveTracks(ctx context.Context, dir string) ([]audio.Track, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var tracks []audio.Track
for _, e := range entries {
if e.IsDir() || !audio.IsAudio(e.Name()) {
continue
}
t, err := audio.Probe(ctx, filepath.Join(dir, e.Name()))
if err != nil {
continue
}
tracks = append(tracks, t)
}
return tracks, nil
}
// ReCoverPlaceholders fetches art again for every album still on a placeholder.
//
// Placeholders accumulate: an album filed before a source had the art, or filed
// offline, sits on a generated stand-in, and replacing them one at a time is the
// kind of chore that never gets done. Only a confident match is used; anything
// less is left alone and reported, because guessing here would replace a
// placeholder with the wrong sleeve, which is worse.
func (a *App) ReCoverPlaceholders(ctx context.Context) error {
j, err := a.journal()
if err != nil {
return err
}
var targets []state.Record
for _, rec := range j.Albums {
if rec.Phase == state.PhaseArchived && rec.Placeholder && rec.ArchiveDir != "" {
targets = append(targets, rec)
}
}
if len(targets) == 0 {
a.logf("no album is on a placeholder cover")
return nil
}
sort.Slice(targets, func(i, k int) bool {
if targets[i].AlbumArtist != targets[k].AlbumArtist {
return targets[i].AlbumArtist < targets[k].AlbumArtist
}
return targets[i].Album < targets[k].Album
})
a.logf("%d album(s) are on placeholder art:", len(targets))
for _, rec := range targets {
a.logf(" %s - %s", rec.AlbumArtist, rec.Album)
}
a.logf("")
a.logf("A confident match is used; anything less is left alone and reported, because")
a.logf("replacing a placeholder with the wrong sleeve is worse than keeping it.")
if !a.Yes {
ok, err := a.confirm("fetch art again for them?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
if a.DryRun {
a.logf("dry run: would look for art for %d album(s)", len(targets))
return nil
}
var fixed, unsure, failed int
for _, rec := range targets {
tracks, err := a.archiveTracks(ctx, rec.ArchiveDir)
if err != nil || len(tracks) == 0 {
failed++
a.logf("%s - %s: could not read its masters", rec.AlbumArtist, rec.Album)
continue
}
var year string
if v, ok := tags.Get(tracks[0].Tags, tags.FieldDate); ok {
year = v
}
info := artwork.AlbumInfo{Artist: rec.AlbumArtist, Album: rec.Album, Year: year, Tracks: len(tracks)}
cands, _, err := artwork.Discover(ctx, info, tracks, artwork.Options{
ArchiveDir: rec.ArchiveDir,
LocalCoverFolder: a.Cfg.CoverFolder,
Offline: a.Cfg.Offline,
CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"),
Sources: []artwork.Source{artwork.SourceCoverArt, artwork.SourceDeezer, artwork.SourceITunes},
Progress: func(msg string) { a.logf(" %s: %s", rec.Album, msg) },
})
if err != nil {
failed++
a.logf("%s - %s: %v", rec.AlbumArtist, rec.Album, err)
continue
}
picked := -1
for i, c := range cands {
if c.Source == artwork.SourcePlaceholder {
continue
}
// Art found in the album's own folder is always right; a remote
// candidate has to say so itself.
if c.Source == artwork.SourceFolder || c.Source == artwork.SourceLocalFolder || c.ExactMatch() {
picked = i
break
}
}
if picked < 0 {
unsure++
a.logf("%s - %s: no confident match (%d candidate(s) offered)", rec.AlbumArtist, rec.Album, len(cands))
continue
}
c := cands[picked]
if err := a.applyCover(ctx, rec, coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, tracks); err != nil {
failed++
a.logf("%s - %s: %v", rec.AlbumArtist, rec.Album, err)
continue
}
fixed++
a.logf("%s - %s: %s", rec.AlbumArtist, rec.Album, c.Label)
}
a.logf("")
a.logf("re-cover: %d fixed, %d left alone for want of a confident match, %d failed", fixed, unsure, failed)
if failed > 0 {
return fmt.Errorf("%d album(s) could not be re-covered", failed)
}
return nil
}
// rebuildLibrary transcodes an archived album into its library directory and
// records that the copy exists.
//
// Recording it is the point. The library copy used to be written without the
// journal hearing about it, so `library verify` went on reporting the album as
// one without a library copy however many times it was rebuilt.
func (a *App) rebuildLibrary(ctx context.Context, rec state.Record) (library.Result, error) {
var res library.Result
masters, names, err := archiveMasters(rec.ArchiveDir)
if err != nil {
return res, err
}
if len(masters) == 0 {
return res, fmt.Errorf("no audio files found in %s", rec.ArchiveDir)
}
coverPath := filepath.Join(rec.ArchiveDir, "cover.jpg")
if _, err := os.Stat(coverPath); err != nil {
coverPath = ""
}
res, err = library.Transcode(ctx, masters, names, rec.LibraryDir, library.Options{
Codec: a.Cfg.Codec,
Quality: a.Cfg.Quality,
ArtMaxPx: a.Cfg.LibraryArtPx,
CoverPath: coverPath,
VerifyDecode: true,
Logf: a.logf,
})
if err != nil {
return res, err
}
for _, w := range res.Warnings {
a.logf(" warning: %s", w)
}
rec.LibraryBuilt = true
rec.LibraryDir = res.Dir
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
j.Put(rec)
return true, nil
}); err != nil {
return res, fmt.Errorf("recording the rebuilt library for %q: %w", rec.Album, err)
}
a.logf("library: %d file(s) -> %s (%s)", len(res.Files), res.Dir, humanBytes(res.Bytes))
return res, nil
}
func findRecord(j *state.Journal, target string) (state.Record, bool) {
target = strings.TrimSpace(target)
if target == "" {
return state.Record{}, false
}
clean := filepath.Clean(target)
want := looseKey(target)
if want == "" {
// A target made only of punctuation folds down to nothing, and
// strings.Contains against "" matches every album, so searching for "!!"
// used to return whichever archived album happened to be alone in the
// journal.
return state.Record{}, false
}
var matches []state.Record
for _, rec := range j.Albums {
if rec.Phase != state.PhaseArchived {
continue
}
if filepath.Clean(rec.ArchiveDir) == clean || rec.Source == target {
return rec, true
}
candidates := []string{
rec.Album,
rec.AlbumArtist,
rec.AlbumArtist + " - " + rec.Album,
filepath.Base(rec.ArchiveDir),
}
for _, c := range candidates {
key := looseKey(c)
if key != "" && (key == want || strings.Contains(key, want)) {
matches = append(matches, rec)
break
}
}
}
if len(matches) == 1 {
return matches[0], true
}
return state.Record{}, false
}
func looseKey(s string) string {
var b strings.Builder
lastDash := true
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
default:
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
func copyCoverFile(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()
}
func countHard(p plan.Plan) int {
n := 0
for _, i := range p.Issues {
if i.Hard {
n++
}
}
return n
}
func (a *App) render(p plan.Plan) {
a.logf("album: %s", p.Album.Name)
a.logf("source: %s", p.Album.Dir)
a.logf("tracks: %d length: %s", len(p.Tracks), mmss(p.Album.TotalDur))
a.logf("")
a.logf("RESOLVED METADATA")
a.logf(" ALBUMARTIST : %s", orUnknown(p.AlbumArt))
a.logf(" ALBUM : %s", orUnknown(p.AlbumName))
a.logf(" DATE : %s", orUnknown(p.Year))
if p.VariousArt {
a.logf(" ARTIST : varies per track -> compilations file under %s/", "various-artists")
}
a.logf("")
a.logf("DESTINATIONS")
a.logf(" archive : %s", orUnknown(p.ArchiveDir))
a.logf(" library : %s (phase 5, not yet implemented)", orUnknown(p.LibraryDir))
a.logf("")
a.logf("TRACKS (original title -> target name)")
for _, t := range p.Tracks {
src := t.Original
if src == "" {
src = filepath.Base(t.Source)
}
mark := " "
if t.Truncated {
mark = "~"
}
a.logf(" %s %-42s -> %s", mark, truncate(src, 42), t.Target)
}
a.logf("")
if len(p.Dropped) > 0 {
a.logf("TAGS TO BE REMOVED: %s", strings.Join(p.Dropped, ", "))
} else {
a.logf("TAGS TO BE REMOVED: none present")
}
a.logf("TAGS KEPT: %s", strings.Join(tags.Keep, ", "))
a.logf("")
if len(p.Warnings) > 0 {
a.logf("WARNINGS")
for _, w := range p.Warnings {
a.logf(" ! %s", w)
}
a.logf("")
}
if len(p.Issues) > 0 {
a.logf("ISSUES")
for _, i := range p.Issues {
kind := "!"
if i.Hard {
kind = "X"
}
a.logf(" %s %s", kind, i.Text)
}
}
}
func (a *App) confirm(prompt string) (bool, error) {
fmt.Fprintf(a.Out, "%s [y/N] ", prompt)
line, err := bufio.NewReader(a.In).ReadString('\n')
if err != nil && line == "" {
return false, nil
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
}
return false, nil
}
func (a *App) promptCollision() (string, error) {
fmt.Fprintf(a.Out, "[s]kip / [r]eplace / [m]erge / [b]oth / [a]bort: ")
line, err := bufio.NewReader(a.In).ReadString('\n')
if err != nil && line == "" {
return "skip", nil
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "r", "replace":
return "replace", nil
case "m", "merge":
return "merge", nil
case "b", "both":
return "both", nil
case "a", "abort":
return "skip", errors.New("aborted by user")
default:
return "skip", nil
}
}
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "(unknown)"
}
return s
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
if n <= 1 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}
func mmss(seconds float64) string {
d := time.Duration(seconds * float64(time.Second))
return fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}
// Existing describes an album that is already in the archive, found before the
// album in front of the user reaches the point of being written.
type Existing struct {
ArchiveDir string
LibraryDir string
AlbumArtist string
Album string
Tracks int
// SamePath is true when the destination is exactly the one in the plan, so
// filing would land on top of it.
SamePath bool
}
// LookupExisting answers "do I already have this?", which the destination path
// alone cannot: a re-download whose artist folder is spelled differently ends up
// somewhere else entirely, and the user only finds out after reviewing the cover
// of an album they already own.
func (a *App) LookupExisting(p plan.Plan) (Existing, bool) {
j, err := a.journal()
if err != nil {
return Existing{}, false
}
if p.ArchiveDir != "" {
if _, err := os.Stat(p.ArchiveDir); err == nil {
return Existing{
ArchiveDir: p.ArchiveDir,
LibraryDir: p.LibraryDir,
AlbumArtist: p.AlbumArt,
Album: p.AlbumName,
Tracks: len(p.Tracks),
SamePath: true,
}, true
}
}
wantArtist := foldCredit(p.AlbumArt)
wantAlbum := foldCredit(p.AlbumName)
if wantArtist == "" || wantAlbum == "" {
return Existing{}, false
}
for _, rec := range j.Albums {
if rec.Phase != state.PhaseArchived {
continue
}
if foldCredit(rec.AlbumArtist) != wantArtist || foldCredit(rec.Album) != wantAlbum {
continue
}
return Existing{
ArchiveDir: rec.ArchiveDir,
LibraryDir: rec.LibraryDir,
AlbumArtist: rec.AlbumArtist,
Album: rec.Album,
Tracks: rec.Tracks,
SamePath: strings.EqualFold(filepath.Clean(rec.ArchiveDir), filepath.Clean(p.ArchiveDir)),
}, true
}
return Existing{}, false
}
// foldCredit makes two spellings of the same credit comparable: case, spacing
// and punctuation are the things that differ between rips of one album.
func foldCredit(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
// BatchReport is what an unattended run did.
type BatchReport struct {
Filed []string
Parked []string
Failed []string
AlreadyFiled int
NothingToFile int
}
// Batch files every ready album in staging without stopping for a decision.
//
// An album that needs a person is parked with the reason recorded against it and
// the run moves on. That is the whole point: one mistagged mp3 out of two hundred
// downloads must not be able to hold up the other hundred and ninety-nine, and
// the album it parks has to still be findable afterwards.
//
// A failure -- a decode that will not verify, a full disk -- is reported and also
// not fatal, because the next album has nothing to do with it. The command still
// exits non-zero at the end if anything failed, so a script can tell.
func (a *App) Batch(ctx context.Context) (BatchReport, error) {
if a.DryRun {
a.logf("dry run: nothing will be filed")
}
var rep BatchReport
j, err := a.journal()
if err != nil {
return rep, err
}
albums, err := a.listStaging(ctx)
if err != nil {
return rep, err
}
// Batch means unattended. Everything that would normally stop and ask is
// answered the unattended way instead.
asked := a.Yes
a.Yes = true
defer func() { a.Yes = asked }()
for _, al := range albums {
if al.Status != scan.StatusReady {
continue
}
if rec, ok := j.Get(al.Dir); ok && rec.Phase == state.PhaseArchived {
rep.AlreadyFiled++
continue
}
if ctx.Err() != nil {
return rep, ctx.Err()
}
full, err := a.probeAlbum(ctx, al.Dir)
if err != nil {
rep.Failed = append(rep.Failed, al.Name)
a.logf("%s: could not read it: %v", al.Name, err)
continue
}
pre := plan.Build(full, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{}, a.Cfg.DiscPrefix)
if pre.Blocked() {
reason := firstHardIssue(pre)
if err := a.park(pre, reason); err != nil {
return rep, err
}
rep.Parked = append(rep.Parked, fmt.Sprintf("%s: %s", al.Name, reason))
a.logf("%s: needs you -- %s", al.Name, reason)
continue
}
a.logf("")
a.logf("=== %s ===", al.Name)
if err := a.process(ctx, full); err != nil {
if reason, ok := leftAlone(err); ok {
if perr := a.park(pre, reason); perr != nil {
return rep, perr
}
rep.Parked = append(rep.Parked, fmt.Sprintf("%s: %s", al.Name, reason))
a.logf("%s: needs you -- %s", al.Name, reason)
continue
}
rep.Failed = append(rep.Failed, fmt.Sprintf("%s: %v", al.Name, err))
a.logf("%s: FAILED -- %v", al.Name, err)
continue
}
rep.Filed = append(rep.Filed, al.Name)
// Re-read the journal so a later album's checks see the ones just filed.
if next, err := a.journal(); err == nil {
j = next
}
}
a.logf("")
a.logf("batch: %d filed, %d parked for you, %d failed, %d already archived",
len(rep.Filed), len(rep.Parked), len(rep.Failed), rep.AlreadyFiled)
for _, p := range rep.Parked {
a.logf(" needs you: %s", p)
}
for _, f := range rep.Failed {
a.logf(" failed: %s", f)
}
if len(rep.Parked) > 0 {
a.logf("")
a.logf("Open one with: intake album <name> (or press J in the interface to see the journal)")
}
if len(rep.Failed) > 0 {
return rep, fmt.Errorf("%d album(s) failed", len(rep.Failed))
}
return rep, nil
}
// park records that an album was left alone and why, so the next run does not
// have to work it out again and the interface can point at it.
func (a *App) park(p plan.Plan, reason string) error {
if a.DryRun {
return nil
}
if strings.TrimSpace(p.AlbumName) == "" {
p.AlbumName = p.Album.Name
}
return a.record(p, state.PhaseNeedsAttention, []string{reason}, false, false)
}
// NeedsAPerson is the error an album that needs a decision is reported with.
// Batch distinguishes it from a real failure: one parks the album and carries on,
// the other is something going wrong.
type NeedsAPerson struct{ Reason string }
func (e NeedsAPerson) Error() string { return "left alone: " + e.Reason }
func (e NeedsAPerson) LeftAlone() string { return e.Reason }
// leftAlone reports whether an error from process means "this album needs a
// person" rather than "something went wrong".
func leftAlone(err error) (string, bool) {
var target interface{ LeftAlone() string }
if errors.As(err, &target) {
return target.LeftAlone(), true
}
return "", false
}
// Adopt builds journal records for albums that are in the archive with none.
//
// The journal is the only index intake has, and an archive can exist without it:
// albums filed by an older version, albums copied in by hand, or an album whose
// record was lost. Without this, those albums are invisible to verify and audit,
// and a batch would happily file a second copy beside them.
//
// The record's key is the archive directory rather than a staging path, because
// the staging path is long gone and inventing one would be a lie. Nothing is
// written to the audio: the tags of the first master are read to fill in the
// artist and album, and that is all.
func (a *App) Adopt(ctx context.Context) error {
j, err := a.journal()
if err != nil {
return err
}
dirs, err := a.archiveDirs()
if err != nil {
return err
}
orphans := a.unrecorded(j, dirs)
if len(orphans) == 0 {
a.logf("nothing to adopt: every album in the archive has a record")
return nil
}
type candidate struct {
dir string
rec state.Record
notes []string
}
var found []candidate
for _, dir := range orphans {
masters, _, err := archiveMasters(dir)
if err != nil || len(masters) == 0 {
a.logf("skipping %s: no audio in it", dir)
continue
}
rec := state.Record{
Source: dir,
ArchiveDir: dir,
LibraryDir: filepath.Join(a.Cfg.Library(), relToArchive(a.Cfg.Archive(), dir)),
Phase: state.PhaseArchived,
Tracks: len(masters),
Adopted: true,
Placeholder: false,
}
// The artist and album come from the masters, which is the only place
// they can come from: the folder names may have been normalised.
first := masters[0]
var notes []string
if info, err := audio.Probe(ctx, first); err == nil {
rec.AlbumArtist, _ = tags.Get(info.Tags, tags.FieldAlbumArtist)
rec.Album, _ = tags.Get(info.Tags, tags.FieldAlbum)
if rec.AlbumArtist == "" {
notes = append(notes, "no ALBUMARTIST tag; the artist folder name stands in")
rec.AlbumArtist = filepath.Base(filepath.Dir(dir))
}
if rec.Album == "" {
notes = append(notes, "no ALBUM tag; the folder name stands in")
rec.Album = filepath.Base(dir)
}
} else {
notes = append(notes, "the first master could not be read: "+err.Error())
rec.AlbumArtist = filepath.Base(filepath.Dir(dir))
rec.Album = filepath.Base(dir)
}
if _, err := os.Stat(filepath.Join(dir, "cover.jpg")); err != nil {
rec.Placeholder = true
notes = append(notes, "no cover.jpg, so it will show up as needing art")
}
if _, err := os.Stat(rec.LibraryDir); err == nil {
rec.LibraryBuilt = true
}
found = append(found, candidate{dir: dir, rec: rec, notes: notes})
}
if len(found) == 0 {
a.logf("nothing to adopt: the albums without records hold no audio either")
return nil
}
a.logf("%d album(s) in the archive have no record:", len(found))
for _, c := range found {
a.logf(" %s - %s", c.rec.AlbumArtist, c.rec.Album)
a.logf(" %s (%d track(s))", c.dir, c.rec.Tracks)
for _, n := range c.notes {
a.logf(" note: %s", n)
}
}
a.logf("")
a.logf("Adopting changes no audio. It writes a record so that verify, audit and")
a.logf("the journal can see these albums, and so a later run does not file them")
a.logf("a second time.")
if !a.Yes {
ok, err := a.confirm("adopt them?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
if a.DryRun {
a.logf("dry run: would adopt %d album(s)", len(found))
return nil
}
adopted := 0
for _, c := range found {
rec := c.rec
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
j.Put(rec)
return true, nil
}); err != nil {
return fmt.Errorf("adopting %q: %w", rec.Album, err)
}
adopted++
}
a.logf("adopted %d album(s)", adopted)
if len(found) != adopted {
return fmt.Errorf("adopted %d of %d", adopted, len(found))
}
return nil
}
// relToArchive turns an archive path into the library path that mirrors it.
func relToArchive(root, dir string) string {
rel, err := filepath.Rel(root, dir)
if err != nil {
return filepath.Base(dir)
}
return rel
}
// Ignore adds staging folders to the list that scan, the album list and a batch
// all skip.
func (a *App) Ignore(names []string) error {
changed := 0
for _, name := range names {
if a.DryRun {
a.logf("dry run: would ignore %q", name)
continue
}
ok, err := a.Cfg.AddIgnore(name)
if err != nil {
return err
}
if ok {
a.logf("ignoring %s", name)
changed++
} else {
a.logf("%s was already ignored", name)
}
}
if changed > 0 {
a.logf("")
a.logf("It stays in staging; `intake ignore` lists it and `intake ignore --remove` puts it back.")
}
return nil
}
func (a *App) Unignore(names []string) error {
for _, name := range names {
if a.DryRun {
a.logf("dry run: would stop ignoring %q", name)
continue
}
ok, err := a.Cfg.RemoveIgnore(name)
if err != nil {
return err
}
if ok {
a.logf("no longer ignoring %s", name)
} else {
a.logf("%s was not ignored", name)
}
}
return nil
}
func (a *App) ListIgnore() error {
set, err := a.Cfg.LoadIgnore()
if err != nil {
return err
}
names := make([]string, 0, len(set))
for n := range set {
names = append(names, n)
}
sort.Strings(names)
if a.JSON {
enc := json.NewEncoder(a.Out)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{"ignore_file": a.Cfg.IgnorePath(), "ignored": names})
}
if len(names) == 0 {
a.logf("nothing is ignored (%s)", a.Cfg.IgnorePath())
return nil
}
a.logf("%d ignored folder(s), from %s:", len(names), a.Cfg.IgnorePath())
for _, n := range names {
a.logf(" %s", n)
}
return nil
}
// RenameArtist moves or merges an artist folder in the archive and the library
// and repoints everything that named it.
//
// Two spellings of one artist is the most common mess a rebuild produces, and
// fixing it by hand means moving two directories, editing every journal record
// underneath them, and finding every artists.toml entry that points at the old
// name. Missing any one of those leaves the journal describing directories that
// no longer exist, which verify then reports as albums that have gone missing.
func (a *App) RenameArtist(ctx context.Context, from, to string, merge bool) error {
from, to = strings.TrimSpace(from), strings.TrimSpace(to)
if from == "" || to == "" {
return errors.New("both the old and the new artist folder name are required")
}
if from == to {
return errors.New("the old and new names are the same")
}
for _, name := range []string{from, to} {
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return fmt.Errorf("%q is not a folder name", name)
}
}
fromArchive, toArchive := filepath.Join(a.Cfg.Archive(), from), filepath.Join(a.Cfg.Archive(), to)
fromLibrary, toLibrary := filepath.Join(a.Cfg.Library(), from), filepath.Join(a.Cfg.Library(), to)
if _, err := os.Stat(fromArchive); err != nil {
return fmt.Errorf("there is no artist folder %s in the archive: %w", from, err)
}
if merge {
if _, err := os.Stat(toArchive); err != nil {
return fmt.Errorf("merging into %s needs it to exist already: %w", to, err)
}
} else if _, err := os.Stat(toArchive); err == nil {
return fmt.Errorf("%s already exists in the archive; merge into it instead, or pick another name", to)
}
plan, err := a.artistMovePlan(fromArchive, toArchive, merge)
if err != nil {
return err
}
if len(plan) == 0 {
a.logf("%s holds no album folders", from)
return nil
}
verb := "rename"
if merge {
verb = "merge"
}
a.logf("%s %s -> %s", verb, from, to)
for _, m := range plan {
a.logf(" %s", m)
}
a.logf("")
a.logf("The audio moves; nothing is copied or deleted. Every journal record beneath")
a.logf("the folder is repointed, and so is every artists.toml entry that named it.")
if !a.Yes {
ok, err := a.confirm("do it?")
if err != nil {
return err
}
if !ok {
a.logf("aborted; nothing changed")
return nil
}
}
if a.DryRun {
a.logf("dry run: nothing was moved")
return nil
}
if merge {
if err := os.MkdirAll(toArchive, 0o755); err != nil {
return err
}
}
if merge {
for _, album := range plan {
fromPath, toPath := filepath.Join(fromArchive, album), filepath.Join(toArchive, album)
if err := os.Rename(fromPath, toPath); err != nil {
return fmt.Errorf("moving %s: %w", album, err)
}
if err := moveMirror(fromLibrary, toLibrary, album); err != nil {
return err
}
}
} else {
if err := os.Rename(fromArchive, toArchive); err != nil {
return fmt.Errorf("moving the artist folder: %w", err)
}
if _, err := os.Stat(fromLibrary); err == nil {
if err := os.Rename(fromLibrary, toLibrary); err != nil {
// The archive has moved; say exactly where things stand rather
// than pretending the whole thing failed.
return fmt.Errorf("the archive was renamed, but the library copy was not: %w", err)
}
}
}
// Anything left empty on the way out goes. os.Remove refuses a non-empty
// directory, so this can only ever tidy up.
os.Remove(fromArchive)
os.Remove(fromLibrary)
moved := 0
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
for _, rec := range j.Albums {
if !underDir(rec.ArchiveDir, fromArchive) && !underDir(rec.LibraryDir, fromLibrary) {
continue
}
if rec.ArchiveDir != "" {
rec.ArchiveDir = repoint(rec.ArchiveDir, fromArchive, toArchive)
}
if rec.LibraryDir != "" {
rec.LibraryDir = repoint(rec.LibraryDir, fromLibrary, toLibrary)
}
j.Put(rec)
moved++
}
return moved > 0, nil
}); err != nil {
return fmt.Errorf("the folders moved, but the journal could not be repointed: %w", err)
}
rewritten, err := a.repointAliases(from, to)
if err != nil {
return fmt.Errorf("the folders moved, but %s could not be updated: %w", artist.AliasPath(a.Cfg.Root), err)
}
a.logf("")
a.logf("moved %d album(s); repointed %d journal record(s) and %d alias(es)", len(plan), moved, rewritten)
return nil
}
// artistMovePlan lists the album folders that will move, and refuses before
// anything is touched if one of them would land on top of an existing album.
func (a *App) artistMovePlan(fromArchive, toArchive string, merge bool) ([]string, error) {
entries, err := os.ReadDir(fromArchive)
if err != nil {
return nil, err
}
var albums []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
if merge {
if _, err := os.Stat(filepath.Join(toArchive, e.Name())); err == nil {
return nil, fmt.Errorf("%s already has an album called %q; rename that one first, or file it again", filepath.Base(toArchive), e.Name())
}
}
albums = append(albums, e.Name())
}
sort.Strings(albums)
return albums, nil
}
// moveMirror moves one album inside the library tree, if it is there at all.
func moveMirror(fromLibrary, toLibrary, album string) error {
src := filepath.Join(fromLibrary, album)
if _, err := os.Stat(src); err != nil {
return nil
}
if err := os.MkdirAll(toLibrary, 0o755); err != nil {
return err
}
return os.Rename(src, filepath.Join(toLibrary, album))
}
func underDir(path, dir string) bool {
if path == "" || dir == "" {
return false
}
rel, err := filepath.Rel(dir, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func repoint(path, from, to string) string {
rel, err := filepath.Rel(from, path)
if err != nil {
return path
}
return filepath.Join(to, rel)
}
// repointAliases moves every artists.toml entry that named the old folder.
func (a *App) repointAliases(from, to string) (int, error) {
ix, err := a.artistIndex("")
if err != nil {
return 0, err
}
changed := 0
for credit, dir := range ix.Alias {
if dir != from {
continue
}
ix.Alias[credit] = to
changed++
}
if changed == 0 || a.DryRun {
return changed, nil
}
if err := ix.SaveAliases(artist.AliasPath(a.Cfg.Root)); err != nil {
return changed, err
}
return changed, nil
}