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 ") } 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 ") } 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 ") } 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 (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 }