git/intake/main

feat: file a batch unattended, parking what needs a decision

'intake batch' files every ready album in one pass. An album that needs a
person is parked -- recorded with the reason and left exactly where it is --
and the run moves on. A genuine failure is reported and also not fatal,
because the next album has nothing to do with it; the command exits
non-zero at the end if anything failed, so a script can tell 'finished'
from 'finished with problems'.

This is the biggest practical blocker on a large collection: one mistagged
mp3 in a directory of two hundred downloads used to stop everything behind
it, and 'resume' stopped at the first ready album whatever state it was in.

Parking is a journal phase, not a line in a log, so the album stays
findable: library verify and the journal screen show it with its reason,
and running the batch again parks it again rather than losing it.

In the interface, 'b' on the album list runs the same batch with progress
on the working screen and a summary that names every album it parked
rather than only counting them. The album list marks parked albums in
place of their status line, and 'n' jumps the cursor to the next one,
which is how somebody works through the backlog a batch leaves behind.

Closes 1c36deb
diff --git a/README.md b/README.md
index 45ebe93..6890e0f 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,8 @@ produces a clean, consistently tagged, consistently named library: lossless mast
- Transcodes a streaming copy from the masters and optimises its cover
- Remembers artist folder decisions in `artists.toml` and the journal, so the same
artist always lands in the same folder
+- `b` on the album list files everything ready in one unattended pass, parking whatever
+ needs a decision and naming it at the end; `n` jumps straight to the next parked album
- `J` on the album list opens the journal: every record, with what is actually on disk
beside it, and per record the choice to forget it (files untouched) or move the masters
back to staging so it can be reviewed again
@@ -49,6 +51,7 @@ intake album <name> # review one album, then archive it
intake album <name> --dry-run # show the full plan, change nothing
intake album <name> --edit-names # open the album sheet first
intake resume # continue the first unarchived album
+intake batch # file every ready album, parking the ones that need you
intake library verify # placeholder covers and missing libraries
intake library audit # compare archive and library for drift
intake library transcode <album> # rebuild a streaming copy from the masters
@@ -65,6 +68,12 @@ Every command accepts `--root`, `--config`, `--state-dir`, `--dry-run`, `--yes`,
`--yes` approves automatically, but refuses when the plan has hard issues (unknown
album or artist, colliding names), so it can never silently invent metadata.
+`intake batch` files everything ready in one pass. An album that needs a decision — an
+unknown artist, a name that cannot be normalised, a file that is not lossless — is parked
+with the reason recorded against it and the run carries on, because one bad download must
+not hold up the other two hundred. In the interface, `b` runs the same batch and `n` jumps
+to the next parked album.
+
Everything about an album is editable before it is filed. Pressing `e` on any review
screen — including the cover picker — opens the album sheet in `$EDITOR`: the album and
album artist, both folder names, the year, the cover, and every track's title, artist,
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
index 7082e8a..f4c7f93 100644
--- a/cmd/intake/main.go
+++ b/cmd/intake/main.go
@@ -25,6 +25,7 @@ Commands:
scan List albums found in the staging directory
album <name> Process one album (metadata, names, then archive)
resume Continue the first album that is not archived yet
+ batch File every ready album, parking the ones that need you
library verify Check the archive and list placeholder covers
library re-cover Replace the cover of an archived album
library transcode Rebuild an album's streaming copy from its masters
@@ -199,6 +200,19 @@ func run(ctx context.Context, args []string) error {
}
return app(cfg, cf).Album(ctx, strings.Join(fs.Args(), " "))
+ case "batch":
+ fs := flag.NewFlagSet("batch", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ _, err = app(cfg, cf).Batch(ctx)
+ return err
+
case "resume":
fs := flag.NewFlagSet("resume", flag.ExitOnError)
cf := register(fs)
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f897812..20bc0c4 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -193,6 +193,18 @@ the masters back to staging and removes the library copy, which is derived. Un-f
moves rather than copies, because after filing the archive holds the only copy of that
audio.
+## Filing unattended
+
+`batch` files every ready album without stopping for a decision. An album that needs a
+person is parked — recorded with the reason, left exactly where it is — and the run moves
+on. That is the whole point: one mistagged mp3 out of two hundred downloads must not hold
+up the other hundred and ninety-nine, and the album it parks has to still be findable
+afterwards, which it is, because the park is a journal record rather than a line in a log.
+
+A genuine failure is reported and also not fatal, because the next album has nothing to do
+with it. The command exits non-zero at the end if anything failed, so a script can tell the
+difference between "finished" and "finished with problems".
+
## The album sheet
Everything intake is about to write is editable in one `$EDITOR` document, reachable from
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index a229a57..0be9a3f 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -321,11 +321,12 @@ func (a *App) process(ctx context.Context, al scan.Album) error {
a.logf("")
a.logf("REFUSING to proceed: %d issue(s) must be resolved first.", countHard(p))
if a.Yes {
- return errors.New("hard issues present; refusing --yes")
+ return NeedsAPerson{Reason: firstHardIssue(p)}
}
- // Not an error, but not a success either: a caller that cannot see the
- // output has to be able to tell that the album was left alone.
- return fmt.Errorf("left alone: %s", 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("")
@@ -836,6 +837,9 @@ type JournalEntry struct {
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
@@ -858,6 +862,10 @@ func (a *App) JournalEntries(ctx context.Context) ([]JournalEntry, error) {
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
@@ -1435,3 +1443,142 @@ func humanBytes(n int64) string {
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}
+
+// 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
+}
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
index e9ab5f4..a952598 100644
--- a/internal/cli/journal_test.go
+++ b/internal/cli/journal_test.go
@@ -2,8 +2,10 @@ package cli
import (
"bytes"
+ "context"
"encoding/json"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
@@ -385,3 +387,78 @@ func TestJournalEntriesShowWhatIsOnDisk(t *testing.T) {
t.Errorf("an album with no record was not listed: %+v", e)
}
}
+
+// Batch files everything it can and parks what needs a person, because one bad
+// download out of two hundred must not hold up the other hundred and ninety-nine.
+func TestBatchParksWhatNeedsYouAndCarriesOn(t *testing.T) {
+ needTools(t, "ffmpeg", "metaflac", "flac", "magick")
+ root := t.TempDir()
+ a, out := cliApp(t, root)
+ a.Cfg.NoCover = true
+ a.Cfg.NoLibrary = true
+
+ // One album that cannot be worked out: untagged files with no separator in
+ // the folder name.
+ blocked := filepath.Join(a.Cfg.Staging(), "a-blocked")
+ if err := os.MkdirAll(blocked, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "sine=frequency=300:duration=0.1", filepath.Join(blocked, "01 one.flac"))
+ if o, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("building the blocked fixture: %v %s", err, o)
+ }
+ good := stagedAlbum(t, a, "b-good", 700)
+
+ rep, err := a.Batch(context.Background())
+ if err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+ if len(rep.Filed) != 1 || rep.Filed[0] != "b-good" {
+ t.Errorf("filed = %v", rep.Filed)
+ }
+ if len(rep.Parked) != 1 || !strings.Contains(rep.Parked[0], "a-blocked") {
+ t.Errorf("parked = %v", rep.Parked)
+ }
+ if _, err := os.Stat(filepath.Join(good.ArchiveDir, "1-01_track-1.flac")); err != nil {
+ t.Errorf("the good album was not filed: %v", err)
+ }
+ if _, err := os.Stat(blocked); err != nil {
+ t.Errorf("the parked album should have been left alone: %v", err)
+ }
+
+ // The park is recorded, so the interface can point at it and the reason is
+ // not thrown away with the log.
+ entries, err := a.JournalEntries(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, e := range entries {
+ if e.NeedsAttention && e.Source == blocked {
+ found = true
+ if e.Reason == "" {
+ t.Error("the parked album has no reason recorded")
+ }
+ }
+ }
+ if !found {
+ t.Errorf("the parked album was not recorded: %+v", entries)
+ }
+
+ // Running it again files nothing new and parks the same album again, so a
+ // batch is safe to repeat.
+ rep2, err := a.Batch(context.Background())
+ if err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+ if len(rep2.Filed) != 0 {
+ t.Errorf("a second batch filed %v", rep2.Filed)
+ }
+ if len(rep2.Parked) != 1 {
+ t.Errorf("a second batch parked %v", rep2.Parked)
+ }
+ if len(rep2.Failed) != 0 {
+ t.Errorf("a second batch failed on %v", rep2.Failed)
+ }
+}
diff --git a/internal/state/state.go b/internal/state/state.go
index b3cf079..2d5c06b 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -16,6 +16,11 @@ const (
PhaseReviewed Phase = "reviewed"
PhaseArchived Phase = "archived"
PhaseFailed Phase = "failed"
+ // PhaseNeedsAttention is an album an unattended run left alone because
+ // something about it needs a person: an unknown artist, a name that cannot
+ // be normalised, a file that is not lossless. It is not a failure -- nothing
+ // was written -- and the album is still sitting in staging.
+ PhaseNeedsAttention Phase = "needs_attention"
)
type Record struct {
diff --git a/internal/tui/runner.go b/internal/tui/runner.go
index 526e8da..f91b917 100644
--- a/internal/tui/runner.go
+++ b/internal/tui/runner.go
@@ -85,6 +85,36 @@ func (r *Runner) Journal(ctx context.Context) ([]JournalEntry, error) {
return out, nil
}
+// Attention maps each staging directory an unattended run parked to the reason.
+func (r *Runner) Attention(ctx context.Context) (map[string]string, error) {
+ app := r.app(nil)
+ entries, err := app.JournalEntries(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]string{}
+ for _, e := range entries {
+ if e.NeedsAttention {
+ reason := e.Reason
+ if reason == "" {
+ reason = "needs a decision"
+ }
+ out[e.Source] = reason
+ }
+ }
+ return out, nil
+}
+
+func (r *Runner) Batch(ctx context.Context, progress func(string)) (BatchReport, error) {
+ logf := func(format string, args ...any) {
+ if progress != nil {
+ progress(strings.TrimSpace(fmt.Sprintf(format, args...)))
+ }
+ }
+ rep, err := r.app(logf).Batch(ctx)
+ return BatchReport{Filed: rep.Filed, Parked: rep.Parked, Failed: rep.Failed}, err
+}
+
func (r *Runner) ForgetSource(source string) error {
return r.app(nil).ForgetSource(source)
}
@@ -159,6 +189,8 @@ func Run(cfg config.Config, out io.Writer, in io.Reader) error {
Run: r.Run,
CoverOptions: r.CoverOptions,
ApplyCover: r.ApplyCover,
+ Attention: r.Attention,
+ Batch: r.Batch,
Journal: r.Journal,
ForgetSource: r.ForgetSource,
UnfileSource: r.UnfileSource,
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 286ee52..0dfe3db 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -62,6 +62,15 @@ type Options struct {
Journal func(ctx context.Context) ([]JournalEntry, error)
ForgetSource func(source string) error
UnfileSource func(source string, progress func(string)) error
+ Batch func(ctx context.Context, progress func(string)) (BatchReport, error)
+ Attention func(ctx context.Context) (map[string]string, error)
+}
+
+// BatchReport is what an unattended run did, for the summary at the end.
+type BatchReport struct {
+ Filed []string
+ Parked []string
+ Failed []string
}
// JournalEntry is one line of the journal screen: what intake remembers about an
@@ -133,6 +142,8 @@ type Module struct {
coversFetched bool
placed *kittyPlacement
+ attention map[string]string
+ batch *BatchReport
journal []JournalEntry
journalCursor int
journalOffset int
@@ -196,9 +207,25 @@ func (m Module) journalCmd() tea.Cmd {
}
}
+type attentionMsg struct {
+ reasons map[string]string
+ err error
+}
+
+func (m Module) attentionCmd() tea.Cmd {
+ get := m.opts.Attention
+ return func() tea.Msg {
+ reasons, err := get(context.Background())
+ return attentionMsg{reasons: reasons, err: err}
+ }
+}
+
type doneMsg struct {
outcome Outcome
err error
+ // batch, when set, means the working screen was filing a whole staging
+ // directory rather than one album.
+ batch *BatchReport
}
type progressMsg string
@@ -284,6 +311,30 @@ func (m Module) runCmd(ctx context.Context, a scan.Album) tea.Cmd {
}
}
+func (m Module) batchCmd(ctx context.Context) tea.Cmd {
+ run, events := m.opts.Batch, m.events
+ return func() tea.Msg {
+ go func() {
+ defer close(events)
+ rep, err := run(ctx, func(line string) {
+ select {
+ case events <- progressMsg(line):
+ case <-ctx.Done():
+ }
+ })
+ select {
+ case events <- doneMsg{err: err, batch: &rep}:
+ case <-ctx.Done():
+ }
+ }()
+ msg, ok := <-events
+ if !ok {
+ return nil
+ }
+ return msg
+ }
+}
+
func (m Module) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -301,6 +352,9 @@ func (m Module) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.albums = msg.albums
m.cursor, m.offset = 0, 0
m.status = fmt.Sprintf("%d album(s) staged", len(m.albums))
+ if m.opts.Attention != nil {
+ return m, m.attentionCmd()
+ }
return m, nil
case planMsg:
@@ -342,16 +396,29 @@ func (m Module) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
return m, tea.Batch(m.placeCover(), replantTick())
+ case attentionMsg:
+ if msg.err == nil {
+ m.attention = msg.reasons
+ }
+ return m, nil
+
case doneMsg:
if msg.err != nil {
m.err, m.status = msg.err, msg.err.Error()
- m.screen = ScreenReview
- return m, nil
+ if msg.batch == nil {
+ m.screen = ScreenReview
+ return m, nil
+ }
}
- m.outcome = msg.outcome
+ m.outcome, m.batch = msg.outcome, msg.batch
m.screen = ScreenDone
m.scroll = 0
- m.status = "filed"
+ switch {
+ case msg.batch != nil:
+ m.status = fmt.Sprintf("%d filed, %d parked, %d failed", len(msg.batch.Filed), len(msg.batch.Parked), len(msg.batch.Failed))
+ default:
+ m.status = "filed"
+ }
return m, nil
case journalMsg:
@@ -474,7 +541,7 @@ func (m Module) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil
case "enter", "esc":
m.screen = ScreenBrowse
- m.scroll = 0
+ m.scroll, m.batch = 0, nil
return m, m.scanCmd()
}
return m, nil
@@ -525,6 +592,29 @@ func (m Module) keyBrowse(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
}
m.status = "reading the journal"
return m, m.journalCmd()
+ case "n":
+ return m.jumpToAttention()
+ case "b":
+ if m.opts.Batch == nil {
+ m.status = "this build cannot file a batch"
+ return m, nil
+ }
+ ready := 0
+ for _, a := range m.visibleAlbums() {
+ if a.Status == scan.StatusReady {
+ ready++
+ }
+ }
+ if ready == 0 {
+ m.status = "nothing ready to file"
+ return m, nil
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ m.cancel = cancel
+ m.events = make(chan tea.Msg, 64)
+ m.screen, m.log, m.follow, m.batch = ScreenWorking, nil, true, nil
+ m.status = fmt.Sprintf("filing %d album(s) unattended", ready)
+ return m, tea.Batch(m.spin.Tick, m.batchCmd(ctx))
case "up", "k":
if m.cursor > 0 {
m.cursor--
@@ -546,6 +636,25 @@ func (m Module) keyBrowse(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
return m, nil
}
+// jumpToAttention moves the cursor to the next album an unattended run parked.
+func (m Module) jumpToAttention() (tea.Model, tea.Cmd) {
+ visible := m.visibleAlbums()
+ if len(visible) == 0 || len(m.attention) == 0 {
+ m.status = "no album is waiting on you"
+ return m, nil
+ }
+ for i := 1; i <= len(visible); i++ {
+ j := (m.cursor + i) % len(visible)
+ if _, ok := m.attention[visible[j].Dir]; ok {
+ m.cursor = j
+ m.status = m.attention[visible[j].Dir]
+ return m, nil
+ }
+ }
+ m.status = "no album is waiting on you"
+ return m, nil
+}
+
func (m Module) keyReview(key string) (tea.Model, tea.Cmd) {
switch key {
case "up", "k":
@@ -712,6 +821,7 @@ func (m *Module) resetAlbum() {
m.placed = nil
m.sheetPath, m.sheetBefore = "", nil
m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
+ m.batch = nil
m.log, m.outcome = nil, Outcome{}
m.err, m.status = nil, ""
m.scroll, m.offset = 0, 0
@@ -843,7 +953,7 @@ func (m Module) renderFooter() string {
if m.filtering {
hints = []string{"enter", "apply", "esc", "cancel"}
} else {
- hints = []string{"↑↓", "move", "/", "filter", "enter", "review", "e", "edit album", "J", "journal", "r", "refresh", "q", "quit"}
+ hints = []string{"↑↓", "move", "/", "filter", "enter", "review", "b", "file all", "n", "next that needs you", "J", "journal", "r", "refresh", "q", "quit"}
}
case ScreenJournal:
if m.journalMenu >= 0 {
@@ -1058,6 +1168,9 @@ func (m Module) emptyStaging() string {
}
func (m Module) albumSummary(a scan.Album) string {
+ if reason, ok := m.attention[a.Dir]; ok {
+ return "needs you: " + reason
+ }
parts := []string{string(a.Status)}
switch {
case len(a.Tracks) > 0:
@@ -1182,6 +1295,7 @@ func (m Module) keyJournalMenu(keyp string) (tea.Model, tea.Cmd) {
switch keyp {
case "esc", "q":
m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
+ m.batch = nil
return m, nil
case "up", "k":
if m.journalAction > 0 {
@@ -1211,5 +1325,6 @@ func (m Module) keyJournalMenu(keyp string) (tea.Model, tea.Cmd) {
func (m Module) runJournalAction(a journalAction) (tea.Model, tea.Cmd) {
entry := m.journal[m.journalCursor]
m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
+ m.batch = nil
return m, a.run(&m, entry)
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 1e9b955..36c8044 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -696,3 +696,102 @@ func TestJournalFlagsSayWhatIsWrong(t *testing.T) {
t.Errorf("a complete record should say so: %q", got)
}
}
+
+func batchModule(t *testing.T, rep BatchReport, parked map[string]string) Module {
+ t.Helper()
+ m := testModule(120, 40)
+ m.albums = albums()
+ m.ready = true
+ m.attention = parked
+ m.opts.Batch = func(context.Context, func(string)) (BatchReport, error) { return rep, nil }
+ m.opts.Attention = func(context.Context) (map[string]string, error) { return parked, nil }
+ return m
+}
+
+// The whole point of an unattended run is that it does not stop, so the key that
+// starts one has to be on the album list and the working screen has to be able to
+// show it.
+func TestBatchStartsFromTheAlbumList(t *testing.T) {
+ m := batchModule(t, BatchReport{}, nil)
+ got, cmd := m.handleKey(key("b"))
+ m = got.(Module)
+ if cmd == nil {
+ t.Fatal("'b' should start a batch")
+ }
+ if m.screen != ScreenWorking {
+ t.Fatalf("screen = %v, want the working screen", m.screen)
+ }
+ if !strings.Contains(m.status, "unattended") {
+ t.Errorf("status = %q", m.status)
+ }
+}
+
+func TestBatchRefusesWhenNothingIsReady(t *testing.T) {
+ m := batchModule(t, BatchReport{}, nil)
+ m.albums = []scan.Album{{Name: "empty", Status: scan.StatusEmpty}}
+ got, cmd := m.handleKey(key("b"))
+ m = got.(Module)
+ if cmd != nil {
+ t.Error("a batch with nothing ready should not start")
+ }
+ if !strings.Contains(m.status, "nothing ready") {
+ t.Errorf("status = %q", m.status)
+ }
+}
+
+// The parked albums are the ones that still need a person, so they are named in
+// the summary rather than only counted.
+func TestBatchSummaryNamesWhatNeedsYou(t *testing.T) {
+ m := batchModule(t, BatchReport{
+ Filed: []string{"one", "two"},
+ Parked: []string{"three: album artist is unknown"},
+ Failed: []string{"four: decode failed"},
+ }, nil)
+ rep := BatchReport{
+ Filed: []string{"one", "two"},
+ Parked: []string{"three: album artist is unknown"},
+ Failed: []string{"four: decode failed"},
+ }
+ m.batch = &rep
+ m.screen = ScreenDone
+ out := m.View().Content
+ for _, want := range []string{"filed 2 album", "need you", "three", "four"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("the summary does not mention %q:\n%s", want, out)
+ }
+ }
+}
+
+// 'n' walks to the next album an unattended run parked, which is how somebody
+// works through the backlog a batch leaves behind.
+func TestJumpingToTheNextAlbumThatNeedsYou(t *testing.T) {
+ m := batchModule(t, BatchReport{}, map[string]string{"/staging/boards": "no artist"})
+ m.cursor = 0
+ got, _ := m.jumpToAttention()
+ m = got.(Module)
+ if m.albums[m.cursor].Dir != "/staging/boards" {
+ t.Errorf("cursor is on %q", m.albums[m.cursor].Name)
+ }
+ if m.status != "no artist" {
+ t.Errorf("status = %q, want the reason", m.status)
+ }
+
+ m.attention = map[string]string{}
+ got, _ = m.jumpToAttention()
+ m = got.(Module)
+ if !strings.Contains(m.status, "no album is waiting") {
+ t.Errorf("status = %q", m.status)
+ }
+}
+
+// The album list says which ones are parked, because otherwise an album that
+// needs a decision looks exactly like one that is ready.
+func TestTheAlbumListMarksWhatNeedsYou(t *testing.T) {
+ m := batchModule(t, BatchReport{}, map[string]string{"/staging/boards": "no artist"})
+ if got := m.albumSummary(scan.Album{Dir: "/staging/boards", Status: scan.StatusReady}); !strings.Contains(got, "needs you") {
+ t.Errorf("summary = %q", got)
+ }
+ if got := m.albumSummary(scan.Album{Dir: "/staging/aphex-twin", Status: scan.StatusReady}); strings.Contains(got, "needs you") {
+ t.Errorf("a ready album was marked: %q", got)
+ }
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
index d96c1b3..90edfaf 100644
--- a/internal/tui/view.go
+++ b/internal/tui/view.go
@@ -347,7 +347,46 @@ func (m Module) viewWorking() string {
return b.String()
}
+// viewBatchDone is the summary of an unattended run: what was filed, what was
+// parked, and what went wrong. The parked ones are the point -- they are the
+// albums that still need a person, and they are listed by name so the next step
+// is obvious.
+func (m Module) viewBatchDone() string {
+ rep := m.batch
+ var b strings.Builder
+ head := fmt.Sprintf("filed %d album%s", len(rep.Filed), plural(len(rep.Filed)))
+ b.WriteString(" " + m.st.Success.Render("✓ "+head) + "\n\n")
+
+ rows := []string{
+ m.field("filed", fmt.Sprintf("%d", len(rep.Filed)), m.width),
+ m.field("need you", fmt.Sprintf("%d", len(rep.Parked)), m.width),
+ m.field("failed", fmt.Sprintf("%d", len(rep.Failed)), m.width),
+ }
+ b.WriteString(panelWith(m.st, "", strings.Join(rows, "\n"), m.width, true))
+ b.WriteString("\n\n")
+
+ if len(rep.Parked) > 0 {
+ b.WriteString(" " + m.st.Warning.Render("These need a decision; nothing was written for them:") + "\n")
+ for _, p := range rep.Parked {
+ b.WriteString(" " + m.st.Muted.Render("• "+truncateTo(p, m.width-8)) + "\n")
+ }
+ b.WriteString("\n " + m.st.Muted.Render("Press n on the album list to jump to the next one.") + "\n\n")
+ }
+ if len(rep.Failed) > 0 {
+ b.WriteString(" " + m.st.Danger.Render("These failed and may need looking at:") + "\n")
+ for _, f := range rep.Failed {
+ b.WriteString(" " + m.st.Muted.Render("• "+truncateTo(f, m.width-8)) + "\n")
+ }
+ b.WriteString("\n")
+ }
+ b.WriteString(" " + m.st.Muted.Render("Press enter to go back to the album list.") + "\n")
+ return b.String()
+}
+
func (m Module) viewDone() string {
+ if m.batch != nil {
+ return m.viewBatchDone()
+ }
var b strings.Builder
b.WriteString(" " + m.st.Success.Render("✓ filed") + " " + m.st.Title.Render(m.current.Name) + "\n\n")