initial commit
A music library intake utility. It scans a staging directory, reviews each album for metadata, track names and cover art, writes lossless masters and a Vorbis streaming copy into separate trees, and verifies every file before it lands. It is a personal tool built around one specific library layout rather than a general-purpose one, and it is public only in case someone finds a piece of it useful. Written almost entirely by DeepSeek V4.1 Flash (High Effort) in DeepSeek Harness, working from the repository owner's direction. The requirements, the design decisions and the bug reports are theirs; the code is not.
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,6 @@
+/out/
+*.test
+*.exe
+*.log
+.scratch/
+__pycache__/
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+software, either in source code form or as a compiled binary, for any purpose,
+commercial or non-commercial, and by any means.
+
+In jurisdictions that recognize copyright laws, the author or authors of this
+software dedicate any and all copyright interest in the software to the public
+domain. We make this dedication for the benefit of the public at large and to
+the detriment of our heirs and successors. We intend this dedication to be an
+overt act of relinquishment in perpetuity of all present and future rights to
+this software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org/>
diff --git a/Makefile b/Makefile
@@ -0,0 +1,26 @@
+BIN := out/intake
+
+.PHONY: all build run fmt vet test check clean
+
+all: build
+
+build:
+ @mkdir -p out
+ go build -trimpath -o $(BIN) ./cmd/intake
+
+run: build
+ ./$(BIN)
+
+fmt:
+ gofmt -l -w .
+
+vet:
+ go vet ./...
+
+test:
+ go test ./...
+
+check: fmt vet test
+
+clean:
+ rm -rf out
diff --git a/README.md b/README.md
@@ -0,0 +1,117 @@
+# intake
+
+> This is a personal tool. It exists to solve one specific problem — rebuilding my own
+> lost music collection — and it is shaped entirely around my library layout and the way
+> I like things named. It is custom, it is vibeslopped, and it is not built for general
+> use or for anyone else's setup. It is public in case somebody finds a piece of it
+> useful for whatever reason.
+
+A music library intake utility. Point it at a staging directory full of albums and it
+produces a clean, consistently tagged, consistently named library: lossless masters in
+`archive/` and Vorbis streaming copies in `library/`.
+
+## What it does
+
+- Reviews each album before touching anything: metadata, track names, cover art, then a
+ confirmation step
+- Strips every tag except a fixed whitelist, all of it recomputed rather than copied
+- Names tracks `disc-track_title.ext`, with collision and length guards
+- Finds cover art from embedded art, the folder, the Cover Art Archive, Deezer and iTunes
+- Draws real cover previews in the terminal with the Kitty graphics protocol, falling
+ back to `chafa`
+- Verifies every written master (count, codec, duration, tags, decode) before promoting
+ it into the archive, so a failure leaves staging untouched
+- 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
+
+## Requirements
+
+- `ffmpeg`, `metaflac`, `vorbiscomment`
+- `chafa` is optional, for previews on terminals without Kitty graphics
+
+## Usage
+
+```sh
+intake # full-screen interface
+intake scan # list staged albums
+intake scan --fast # list without reading metadata
+intake album <name> # review one album, then archive it
+intake album <name> --dry-run # show the full plan, change nothing
+intake resume # continue the first unarchived album
+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
+intake library re-cover <album> # replace an album's cover
+intake config # effective config and resolved paths
+```
+
+Every command accepts `--root`, `--config`, `--state-dir`, `--dry-run`, `--yes`,
+`--json`, `--edit-names` and `--fast`. The default root is `~/music`, overridable with
+`--root` or `$INTAKE_ROOT`.
+
+`--yes` approves automatically, but refuses when the plan has hard issues (unknown
+album or artist, colliding names), so it can never silently invent metadata.
+
+When a name cannot be normalised — a Japanese title, a symbol-only title — intake opens
+`$EDITOR` on a small file instead of guessing.
+
+## Layout
+
+```
+<root>/
+ staging/ albums waiting to be processed
+ archive/ <artist>/<album>/ lossless masters + cover.jpg
+ library/ <artist>/<album>/ streaming copies + optimised cover.jpg
+ artists.toml
+```
+
+The journal lives in `~/.local/state/intake/state.json`.
+
+## Configuration
+
+`~/.config/intake/config.toml`. `intake config` prints the resolved values.
+
+## Building
+
+```sh
+make build # -> out/intake
+make check # fmt, vet, test
+```
+
+## Testing
+
+Real music is never used for testing. The fixture generator makes synthetic albums out
+of tiny tones with hand-picked tags, covering multi-disc sets, non-Latin titles,
+collisions, 120-track albums, missing tags and stray clutter:
+
+```sh
+scripts/make-fixtures.sh # into ~/music/staging
+scripts/make-fixtures.sh /tmp/staging # or anywhere else
+```
+
+It is idempotent and takes a couple of seconds. Each fixture directory has a `FIXTURE.md`
+saying what it tests and what the expected behaviour is.
+
+The TUI needs looking at rather than asserting, so there is a harness for that:
+
+```sh
+scripts/tui-tour.py --make-fixtures # prints every screen
+scripts/tui-tour.py --width 120 --height 40
+scripts/grid-probe.py --root .scratch/fx # checks the cover grid reaches the terminal
+```
+
+## How this was built
+
+Pretty much entirely by DeepSeek V4.1 Flash (High Effort) running in DeepSeek Harness,
+working from my direction across a long session. I decided what it should do, ran it
+against real music, and found and filed the bugs, but I did not write the code and it
+does not read like my own work.
+
+That is worth knowing before you judge the style or assume I can explain any given line.
+It also means the parts that are good are mostly the parts where I kept pushing on
+something that was broken.
+
+## License
+
+Released into the public domain under [the Unlicense](LICENSE).
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
@@ -0,0 +1,304 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "os/signal"
+ "strings"
+
+ "intake/internal/cli"
+ "intake/internal/config"
+ "intake/internal/tui"
+)
+
+const version = "0.0.0-dev"
+
+const rootHelp = `intake - music library intake utility
+
+Usage:
+ intake <command> [flags] [args]
+
+Commands:
+ tui Open the full-screen interface (default with no arguments)
+ 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
+ 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
+ library audit Compare the archive and library for drift
+ config Show the effective configuration and resolved paths
+ version Print version information
+ help Show this help
+
+Flags (all commands):
+ --root <dir> library root (default: $INTAKE_ROOT or ~/music)
+ --config <file> config file (default: ~/.config/intake/config.toml)
+ --state-dir <dir> where the journal lives
+ --dry-run show what would happen, change nothing
+ --yes approve automatically (refused if there are hard issues)
+ --json machine-readable output (scan)
+ --edit-names always open the track-name editor, even when names are fine
+ --fast scan: list staging without reading file metadata
+ --no-cover skip the cover art phase
+ --no-library skip building the streaming library copy
+ --offline do not query any network cover source
+
+Run "intake <command> -h" for command-specific help.
+`
+
+func isTerminal(w *os.File) bool {
+ info, err := w.Stat()
+ if err != nil {
+ return false
+ }
+ return info.Mode()&os.ModeCharDevice != 0
+}
+
+func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stop()
+ if err := run(ctx, os.Args[1:]); err != nil {
+ fmt.Fprintf(os.Stderr, "intake: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+type commonFlags struct {
+ root *string
+ conf *string
+ stateDir *string
+ dryRun *bool
+ yes *bool
+ jsonOut *bool
+ editNames *bool
+ fastScan *bool
+ noCover *bool
+ offline *bool
+ noLibrary *bool
+}
+
+func register(fs *flag.FlagSet) *commonFlags {
+ return &commonFlags{
+ root: fs.String("root", "", "library root (contains staging, archive, library)"),
+ conf: fs.String("config", "", "config file path"),
+ stateDir: fs.String("state-dir", "", "directory for the resumable journal"),
+ dryRun: fs.Bool("dry-run", false, "show what would happen without changing anything"),
+ yes: fs.Bool("yes", false, "approve automatically (refused if there are hard issues)"),
+ jsonOut: fs.Bool("json", false, "machine-readable output"),
+ editNames: fs.Bool("edit-names", false, "always open the track-name editor"),
+ fastScan: fs.Bool("fast", false, "list staging without reading file metadata (scan only)"),
+ noCover: fs.Bool("no-cover", false, "skip the cover art phase entirely"),
+ offline: fs.Bool("offline", false, "do not query any network cover source"),
+ noLibrary: fs.Bool("no-library", false, "skip building the streaming library copy"),
+ }
+}
+
+func resolve(cf *commonFlags) (config.Config, error) {
+ cfg, err := config.Load(*cf.conf)
+ if err != nil {
+ return cfg, err
+ }
+ if *cf.root != "" {
+ cfg.Root = *cf.root
+ }
+ if *cf.stateDir != "" {
+ cfg.StateDir = *cf.stateDir
+ }
+ cfg.DryRun = *cf.dryRun
+ cfg.Yes = *cf.yes
+ if *cf.noCover {
+ cfg.NoCover = true
+ }
+ if *cf.offline {
+ cfg.Offline = true
+ }
+ if *cf.noLibrary {
+ cfg.NoLibrary = true
+ }
+ return cfg, nil
+}
+
+func app(cfg config.Config, cf *commonFlags) *cli.App {
+ jsonOut := *cf.jsonOut
+ a := cli.New(cfg, os.Stdout, os.Stderr, os.Stdin)
+ a.DryRun = cfg.DryRun
+ a.Yes = cfg.Yes
+ a.JSON = jsonOut
+ a.EditNames = *cf.editNames
+ a.FastScan = *cf.fastScan
+ a.ImagePreview = isTerminal(os.Stdout)
+ return a
+}
+
+func run(ctx context.Context, args []string) error {
+ if len(args) == 0 {
+ if isTerminal(os.Stdout) && isTerminal(os.Stdin) {
+ cfg, err := config.Load("")
+ if err != nil {
+ return err
+ }
+ return tui.Run(cfg, os.Stdout, os.Stdin)
+ }
+ fmt.Print(rootHelp)
+ return nil
+ }
+
+ switch args[0] {
+ case "help", "-h", "--help":
+ fmt.Print(rootHelp)
+ return nil
+ case "version", "--version":
+ fmt.Printf("intake %s\n", version)
+ return nil
+ }
+
+ cmd, rest := args[0], args[1:]
+
+ switch cmd {
+ case "tui":
+ fs := flag.NewFlagSet("tui", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return tui.Run(cfg, os.Stdout, os.Stdin)
+
+ case "scan":
+ fs := flag.NewFlagSet("scan", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Scan(ctx)
+
+ case "album":
+ fs := flag.NewFlagSet("album", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ if fs.NArg() == 0 {
+ return fmt.Errorf("album: an album directory name is required")
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Album(ctx, strings.Join(fs.Args(), " "))
+
+ case "resume":
+ fs := flag.NewFlagSet("resume", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Resume(ctx)
+
+ case "library":
+ if len(rest) == 0 {
+ return fmt.Errorf("library: expected a subcommand (verify)")
+ }
+ switch rest[0] {
+ case "verify":
+ fs := flag.NewFlagSet("library verify", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Verify(ctx)
+ case "transcode":
+ fs := flag.NewFlagSet("library transcode", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ if fs.NArg() == 0 {
+ return fmt.Errorf("library transcode: an album path or name is required")
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Transcode(ctx, strings.Join(fs.Args(), " "))
+ case "audit":
+ fs := flag.NewFlagSet("library audit", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Audit(ctx)
+ case "re-cover":
+ fs := flag.NewFlagSet("library re-cover", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ if fs.NArg() == 0 {
+ return fmt.Errorf("library re-cover: an album path or name is required")
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).ReCover(ctx, strings.Join(fs.Args(), " "))
+ default:
+ return fmt.Errorf("library: unknown subcommand %q", rest[0])
+ }
+
+ case "config":
+ fs := flag.NewFlagSet("config", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ printConfig(cfg)
+ return nil
+
+ default:
+ return fmt.Errorf("unknown command %q (run \"intake help\")", cmd)
+ }
+}
+
+func printConfig(cfg config.Config) {
+ loaded := cfg.Path
+ if loaded == "" {
+ loaded = "(none)"
+ }
+ fmt.Printf("config file : %s\n", loaded)
+ fmt.Printf("root : %s\n", cfg.Root)
+ fmt.Printf("staging : %s\n", cfg.Staging())
+ fmt.Printf("archive : %s\n", cfg.Archive())
+ fmt.Printf("library : %s\n", cfg.Library())
+ fmt.Printf("journal : %s\n", cfg.StateDir+"/state.json")
+ fmt.Printf("editor : %s\n", cfg.EditorCommand())
+ fmt.Printf("codec : %s (quality %d)\n", cfg.Codec, cfg.Quality)
+ fmt.Printf("keep staging : %v\n", cfg.KeepStaging)
+ fmt.Printf("verify decode : %s\n", cfg.VerifyDecode)
+}
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
@@ -0,0 +1,164 @@
+# intake — design
+
+## Goal
+
+Rebuild a lost music collection from a staging directory of albums downloaded from
+Soulseek. The output is two trees: lossless masters in `archive/`, and a streamable
+library in `library/` transcoded from those masters. The archive is the thing that has
+to last, so everything is verified before it lands.
+
+This is a personal tool written for that one job, not a general-purpose one. See "How
+this was built" in the README for what that means in practice.
+
+## Decisions
+
+| | |
+| --- | --- |
+| Archive | `<root>/archive/<artist>/<album>/<disc>-<track>_<title>.flac` plus `cover.jpg` |
+| Library | `<root>/library/<artist>/<album>/` — Vorbis at `-q:a 9`, optimised cover |
+| Tag whitelist | `TRACKNUMBER, TRACKTOTAL, TOTALTRACKS, DISCNUMBER, DISCTOTAL, TOTALDISCS, TITLE, ALBUM, ARTIST, ALBUMARTIST, DATE`, nothing else |
+| Tags are computed | every field is recomputed by intake, never copied from the source |
+| Compilations | `various-artists/<album>` |
+| Cover sources | embedded art, folder image, Cover Art Archive, Deezer, iTunes. No Spotify |
+| Placeholder art | always `cover.jpg`, flagged in the journal so `library verify` can list it |
+| Artist memory | confirmed decisions go in `<root>/artists.toml`, keyed by the raw tag credit |
+| Writes | staged in a temp dir inside `archive/`, verified, then promoted atomically |
+| VCS | jj, unlicensed (public domain) |
+
+The totals were added against the original seven-field list. They describe the shape of
+the release rather than offering an opinion about it, and intake counts them from the
+files it is writing, so they cannot contradict the album. Both spellings are written
+because players disagree about which they read.
+
+## External facts
+
+Verified on this machine, not assumed. Each one cost time to find.
+
+### ffmpeg 9.0.1 cannot tag Opus
+
+- `-c:a libopus -metadata TITLE=...` writes an `OpusTags` header containing only the
+ vendor string. The tags are silently dropped.
+- `ffprobe` cannot read Opus tags either.
+- ffmpeg cannot attach cover art to Opus or Ogg Vorbis at all: `Unsupported codec id in
+ stream 1`.
+- `vorbiscomment` refuses Opus outright.
+
+The container itself is fine — a hand-built `OpusTags` packet with a recomputed Ogg CRC
+reads back correctly in taglib — but hand-writing a tagger to work around a broken
+toolchain is not worth it. **The library is Ogg Vorbis**, which `vorbiscomment` handles
+correctly today. Opus stays a config option for later; the masters are lossless, so the
+library can be regenerated in any codec.
+
+### Vorbis -q:a 9 beats Opus 160k
+
+Measured on an 8 s synthetic signal (pink noise, 55/440/3000 Hz tones, tremolo), decoded
+to 44.1 kHz mono, aligned to the source by integer sample-lag search:
+
+| Format | Size | SNR | Peak error |
+| --- | --- | --- | --- |
+| Vorbis `-q:a 6` | 100 kB | 20.6 dB | 0.067 |
+| Vorbis `-q:a 8` | 121 kB | 23.5 dB | 0.041 |
+| **Vorbis `-q:a 9`** | **153 kB** | **27.3 dB** | **0.028** |
+| Vorbis `-q:a 10` | 239 kB | 33.8 dB | 0.012 |
+| Opus `-b:a 160k` | 148 kB | 15.9 dB | 0.089 |
+| Opus `-b:a 256k` | 238 kB | 16.2 dB | 0.084 |
+
+All encodes measured the same integrated loudness, so none of them shifts level.
+
+Honest caveats: a tone/noise signal flatters Vorbis's MDCT and punishes Opus's
+speech-oriented tuning, so on real music the two converge. Opus decodes at 48 kHz while
+Vorbis keeps 44.1 kHz, so part of Opus's error is an extra resample. This is a
+worst-case probe, not a universal claim — but it is enough to justify the default.
+
+### Kitty graphics
+
+Cover previews use a *virtual placement* (`U=1`) so the image occupies real cells in the
+layout instead of being painted over the interface. The placeholders are ordinary text,
+so the host application can move them around without knowing anything about graphics.
+
+Things that are easy to get wrong, all of which produced a silently blank preview:
+
+- **The image id lives in the cell's foreground colour**, not in extra runes. A cell is
+ the placeholder rune plus three diacritics: row, column, and the id's high byte. The
+ low 24 bits go in an SGR foreground colour.
+- **The transmission declares `f=100`, which means PNG and nothing else.** The protocol
+ has no JPEG format, so a JPEG handed over unchanged is refused with `EBADPNG:Not a
+ PNG file`. Embedded art in FLAC files is usually JPEG, so this is the common case.
+- **The renderer quantises colours to the terminal's profile.** Under a 256-colour
+ profile an id of 7800 is emitted as palette colour 18, which matches no image. The
+ interface therefore imposes a truecolor profile when drawing images, which is safe
+ because the protocol is only selected for terminals that support it.
+- **`charmbracelet/x/ansi/kitty` exposes only the first six row/column diacritics** and
+ silently returns the first for anything past five. The full table (297 marks, generated
+ by `scripts/gen-diacritics.py`) is carried in `internal/tui/diacritics.go`.
+- **A placeholder cell must not be measured as ordinary text.** Counting runes calls a
+ one-column cell four, and the Unicode width tables call `U+10EEEE` three. Either makes
+ the grid measure wider than it is and get clipped.
+- The medium is `t=f`, not `t=t`; both name a path in the payload, but `t=t` claims a
+ temporary file the terminal may delete.
+- No `C=1`. It means "do not move the cursor after placing", which is meaningless for a
+ virtual placement — it has no cursor position, it exists only in cells.
+
+Transmissions go through the renderer as `tea.Raw`, never straight to stdout. Writing
+directly races the frame being drawn and produces nothing at all.
+
+`kitten icat --unicode-placeholder --transfer-mode=file` is the reference to compare
+against, and it is the only reason any of this was found.
+
+## Pipeline
+
+0. **Discovery** — list albums under `staging/`, ordered, with a filter.
+1. **Metadata** — read tags, whitelist them, resolve album artist, report what will
+ change. Album-level values are only used when every track agrees.
+2. **Cover art** — gather candidates, show real previews, let the user pick, or take a
+ placeholder.
+3. **Track naming** — `disc-track_title.ext`, zero-padded to 2 digits widening to 3 at
+ 100+ tracks, truncated on a dash boundary at 200 bytes.
+
+ Names are lowercase, dashes between words, underscores between sections. Symbols
+ that every filesystem accepts — `( ) [ ] + &` — are kept, so a title stays readable
+ rather than collapsing into a run of dashes. Symbols that are illegal somewhere,
+ which is to say the Windows set `< > : " / \ | ? *`, cannot be kept: inside a word
+ they become an underscore (`while(1<2)` becomes `while(1_2)`) so the two characters
+ they sat between stay distinct, and beside a space they are just a separator
+ (`Album: Title` becomes `album-title`) so the words don't glue together as
+ `album_title`. A name with no letters or digits in it counts as unusable and goes to
+ the name editor, the same as an empty one.
+4. **Write masters** — copy into a temp dir inside `archive/`, tag, rename, embed the
+ cover, verify, then promote atomically and delete the staged originals.
+5. **Transcode** — build the streaming copy from the promoted masters, with an optimised
+ cover derived from the one the user actually chose.
+6. **Report** — paths, counts, sizes, warnings, placeholder flag.
+
+Verification before promotion checks count, codec, duration, tags, non-whitelist leakage,
+and decodes the first, middle and last file. A failure at any point leaves staging
+untouched.
+
+## Artist folders
+
+Resolved from the archive tree, the journal and `artists.toml`, in that order, with fuzzy
+and romanisation matching. Every decision is remembered, so the same artist always lands
+in the same folder. Nothing is guessed silently: an unknown album or artist, an empty
+normalised name, or two titles colliding on one target name is a hard issue, and `--yes`
+refuses to proceed rather than inventing metadata.
+
+## State
+
+One journal at `~/.local/state/intake/state.json`, keyed by the staging path, recording
+the phase, the archive and library directories, track count and the placeholder flag.
+`library verify` reconciles it against disk; `library audit` compares the two trees.
+
+## Interface
+
+Bubble Tea v2 (`charm.land/bubbletea/v2`), Bubbles v2 and Lipgloss v2. Screens: browse,
+review (metadata / track names / cover art / confirm), cover picker, working, done.
+
+The interface sets `App.Quiet` and takes progress from the pipeline's return values.
+`stdout` is its drawing surface, so a progress line from the pipeline lands in the middle
+of a frame and corrupts it.
+
+## Not done
+
+- Sixel and iTerm2 image protocols.
+- Opus as a library codec.
+- Markdown panes in the interface.
diff --git a/go.mod b/go.mod
@@ -0,0 +1,30 @@
+module intake
+
+go 1.27.1
+
+require (
+ charm.land/bubbles/v2 v2.2.1
+ charm.land/bubbletea/v2 v2.0.9
+ charm.land/lipgloss/v2 v2.0.6
+ github.com/charmbracelet/x/ansi v0.11.8
+ golang.org/x/text v0.3.8
+)
+
+require (
+ github.com/BourgeoisBear/rasterm v1.1.2 // indirect
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
+ github.com/mattn/go-runewidth v0.0.27 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/term v0.18.0 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,49 @@
+charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
+charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
+charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
+charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
+charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
+charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
+github.com/BourgeoisBear/rasterm v1.1.2 h1:hWHZBZ45N366uNSqxWFYBV0y19q8fXRXADhPkoLF4Ss=
+github.com/BourgeoisBear/rasterm v1.1.2/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
+github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
+golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/internal/artist/artist.go b/internal/artist/artist.go
@@ -0,0 +1,473 @@
+package artist
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/unicode/runenames"
+ "intake/internal/normalize"
+)
+
+var CollabSeparators = []string{
+ " feat. ", " feat ", " ft. ", " ft ", " featuring ",
+ " & ", " and ", " + ", " x ", " × ", " vs. ", " vs ", " versus ",
+ " with ", " / ", " presents ", ", ",
+}
+
+func Split(credit string) ([]string, bool) {
+ parts := []string{credit}
+ changed := false
+ for _, sep := range CollabSeparators {
+ var next []string
+ for _, p := range parts {
+ if strings.Contains(strings.ToLower(p), strings.ToLower(sep)) {
+ changed = true
+ }
+ next = append(next, splitFold(p, sep)...)
+ }
+ parts = next
+ }
+ var out []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ if len(out) == 0 {
+ return []string{credit}, false
+ }
+ return out, changed && len(out) > 1
+}
+
+func splitFold(s, sep string) []string {
+ lower, lsep := strings.ToLower(s), strings.ToLower(sep)
+ var parts []string
+ for {
+ i := strings.Index(lower, lsep)
+ if i < 0 {
+ parts = append(parts, s)
+ break
+ }
+ parts = append(parts, s[:i])
+ s, lower = s[i+len(sep):], lower[i+len(sep):]
+ }
+ return parts
+}
+
+const VariousArtists = "various-artists"
+
+type Candidate struct {
+ Dir string
+ Credit string
+ Score float64
+ ExactAlias bool
+ ExactDir bool
+ RomanizedMatch bool
+ Fuzzy bool
+}
+
+type Match struct {
+ Credit string
+ Dirs []Candidate
+ Exact bool
+ NeedsUser bool
+ Candidates []string
+}
+
+type Index struct {
+ dirs map[string]bool
+ raw map[string]bool
+ Alias map[string]string
+}
+
+func NewIndex() *Index {
+ return &Index{dirs: map[string]bool{}, raw: map[string]bool{}, Alias: map[string]string{}}
+}
+
+func (ix *Index) AddDir(name string) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return
+ }
+ ix.raw[name] = true
+ if k := normalize.DirName(name); k != "" {
+ ix.dirs[k] = true
+ }
+}
+
+func (ix *Index) Dirs() []string {
+ out := make([]string, 0, len(ix.raw))
+ for d := range ix.raw {
+ out = append(out, d)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func (ix *Index) Resolve(credit string) Match {
+ m := Match{Credit: credit}
+ m.Candidates = ix.Dirs()
+
+ trimmed := strings.TrimSpace(credit)
+ if trimmed == "" {
+ return m
+ }
+
+ if dir, ok := ix.Alias[trimmed]; ok && dir != "" {
+ m.Dirs = append(m.Dirs, Candidate{Dir: dir, Credit: trimmed, Score: 1, ExactAlias: true})
+ m.Exact = true
+ return m
+ }
+
+ artists, _ := Split(trimmed)
+ primary := artists[0]
+
+ seen := map[string]bool{}
+ add := func(c Candidate) {
+ if c.Dir == "" || seen[c.Dir] {
+ return
+ }
+ seen[c.Dir] = true
+ m.Dirs = append(m.Dirs, c)
+ }
+
+ for _, name := range append([]string{trimmed}, artists...) {
+ key := normalize.DirName(name)
+ if key != "" && ix.dirs[key] {
+ add(Candidate{Dir: key, Credit: name, Score: 1, ExactDir: true})
+ m.Exact = true
+ }
+ }
+
+ for _, name := range append([]string{trimmed}, artists...) {
+ rom := Romanize(name)
+ if rom == "" {
+ continue
+ }
+ key := normalize.DirName(rom)
+ if key != "" && ix.dirs[key] {
+ add(Candidate{Dir: key, Credit: name, Score: 0.95, RomanizedMatch: true})
+ m.Exact = true
+ continue
+ }
+ for existing := range ix.dirs {
+ if Romanize(existing) != "" && normalize.DirName(Romanize(existing)) == key {
+ add(Candidate{Dir: existing, Credit: name, Score: 0.9, RomanizedMatch: true})
+ m.Exact = true
+ }
+ }
+ }
+
+ type scored struct {
+ dir string
+ score float64
+ }
+ var fuzzy []scored
+ for _, name := range append([]string{trimmed}, artists...) {
+ key := normalize.DirName(name)
+ if key == "" {
+ continue
+ }
+ for existing := range ix.dirs {
+ s := Similarity(key, existing)
+ if s >= 0.75 {
+ fuzzy = append(fuzzy, scored{existing, s})
+ }
+ }
+ }
+ sort.Slice(fuzzy, func(i, j int) bool {
+ if fuzzy[i].score != fuzzy[j].score {
+ return fuzzy[i].score > fuzzy[j].score
+ }
+ return fuzzy[i].dir < fuzzy[j].dir
+ })
+ for _, f := range fuzzy {
+ add(Candidate{Dir: f.dir, Credit: trimmed, Score: f.score, Fuzzy: true})
+ }
+
+ dir := normalize.DirName(primary)
+ if dir == "" {
+ if rom := Romanize(primary); rom != "" {
+ dir = normalize.DirName(rom)
+ }
+ }
+ if dir == "" {
+ dir = "unknown-artist"
+ m.NeedsUser = true
+ }
+ add(Candidate{Dir: dir, Credit: primary, Score: 0})
+
+ return m
+}
+
+func Similarity(a, b string) float64 {
+ if a == "" || b == "" {
+ return 0
+ }
+ if a == b {
+ return 1
+ }
+ tok := tokenOverlap(a, b)
+ ed := editSimilarity(a, b)
+ if tok > ed {
+ return tok
+ }
+ return ed
+}
+
+func tokenOverlap(a, b string) float64 {
+ ta := strings.FieldsFunc(a, func(r rune) bool { return r == '-' })
+ tb := strings.FieldsFunc(b, func(r rune) bool { return r == '-' })
+ if len(ta) == 0 || len(tb) == 0 {
+ return 0
+ }
+ set := map[string]bool{}
+ for _, t := range ta {
+ set[t] = true
+ }
+ common := 0
+ for _, t := range tb {
+ if set[t] {
+ common++
+ }
+ }
+ if common == 0 {
+ return 0
+ }
+ jaccard := float64(common) / float64(len(ta)+len(tb)-common)
+ containment := float64(common) / float64(min(len(ta), len(tb)))
+ if containment > jaccard {
+ return containment
+ }
+ return jaccard
+}
+
+func editSimilarity(a, b string) float64 {
+ d := levenshtein(a, b)
+ maxLen := len(a)
+ if len(b) > maxLen {
+ maxLen = len(b)
+ }
+ if maxLen == 0 {
+ return 0
+ }
+ return 1 - float64(d)/float64(maxLen)
+}
+
+func levenshtein(a, b string) int {
+ ra, rb := []rune(a), []rune(b)
+ prev := make([]int, len(rb)+1)
+ cur := make([]int, len(rb)+1)
+ for j := range prev {
+ prev[j] = j
+ }
+ for i := 1; i <= len(ra); i++ {
+ cur[0] = i
+ for j := 1; j <= len(rb); j++ {
+ cost := 1
+ if ra[i-1] == rb[j-1] {
+ cost = 0
+ }
+ cur[j] = min3(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
+ }
+ prev, cur = cur, prev
+ }
+ return prev[len(rb)]
+}
+
+func min3(a, b, c int) int {
+ if b < a {
+ a = b
+ }
+ if c < a {
+ a = c
+ }
+ return a
+}
+
+func min(a, b int) int {
+ if b < a {
+ return b
+ }
+ return a
+}
+
+func Romanize(s string) string {
+ if !hasNonLatin(s) {
+ return ""
+ }
+ var words []string
+ var pending strings.Builder
+ flush := func() {
+ if pending.Len() > 0 {
+ words = append(words, pending.String())
+ pending.Reset()
+ }
+ }
+ for _, r := range s {
+ if unicode.IsSpace(r) {
+ flush()
+ continue
+ }
+ if unit, ok := translit(r); ok {
+ pending.WriteString(unit)
+ continue
+ }
+ flush()
+ }
+ flush()
+ if len(words) == 0 {
+ return ""
+ }
+ return strings.Join(words, " ")
+}
+
+func translit(r rune) (string, bool) {
+ lower := unicode.ToLower(r)
+ if v, ok := cyrillic[lower]; ok {
+ return v, true
+ }
+ if v, ok := greek[lower]; ok {
+ return v, true
+ }
+ name := runenames.Name(r)
+ if name == "" || strings.HasPrefix(name, "<") {
+ return "", false
+ }
+ if strings.HasPrefix(name, "HIRAGANA LETTER ") || strings.HasPrefix(name, "KATAKANA LETTER ") {
+ w := strings.Fields(name)
+ last := w[len(w)-1]
+ if last == "SMALL" {
+ return "", false
+ }
+ return strings.ToLower(last), true
+ }
+ return "", false
+}
+
+var cyrillic = map[rune]string{
+ 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "yo",
+ 'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m",
+ 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u",
+ 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch",
+ 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
+ 'і': "i", 'ї': "yi", 'є': "ye", 'ґ': "g",
+}
+
+var greek = map[rune]string{
+ 'α': "a", 'β': "v", 'γ': "g", 'δ': "d", 'ε': "e", 'ζ': "z", 'η': "i",
+ 'θ': "th", 'ι': "i", 'κ': "k", 'λ': "l", 'μ': "m", 'ν': "n", 'ξ': "x",
+ 'ο': "o", 'π': "p", 'ρ': "r", 'σ': "s", 'ς': "s", 'τ': "t", 'υ': "y",
+ 'φ': "f", 'χ': "ch", 'ψ': "ps", 'ω': "o",
+ 'ά': "a", 'έ': "e", 'ή': "i", 'ί': "i", 'ό': "o", 'ύ': "y", 'ώ': "o",
+ 'ϊ': "i", 'ϋ': "y", 'ΐ': "i", 'ΰ': "y",
+}
+
+func hasNonLatin(s string) bool {
+ for _, r := range s {
+ if r > unicode.MaxASCII && unicode.IsLetter(r) {
+ return true
+ }
+ }
+ return false
+}
+
+func AliasPath(root string) string { return filepath.Join(root, "artists.toml") }
+
+func (ix *Index) LoadAliases(path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ defer f.Close()
+
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
+ continue
+ }
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ value = strings.Trim(strings.TrimSpace(value), `"'`)
+ if key == "" || value == "" {
+ continue
+ }
+ ix.Alias[unquoteTOML(key)] = value
+ }
+ return sc.Err()
+}
+
+func (ix *Index) SetAlias(rawCredit, dir string) {
+ if ix.Alias == nil {
+ ix.Alias = map[string]string{}
+ }
+ ix.Alias[strings.TrimSpace(rawCredit)] = dir
+}
+
+func (ix *Index) SaveAliases(path string) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ keys := make([]string, 0, len(ix.Alias))
+ for k := range ix.Alias {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var b strings.Builder
+ b.WriteString("# intake artist folder decisions\n")
+ b.WriteString("#\n")
+ b.WriteString("# Maps a raw artist credit, exactly as it appears in the tags, to the\n")
+ b.WriteString("# folder it belongs in under archive/ and library/. intake writes an\n")
+ b.WriteString("# entry here whenever you confirm an artist, so the same credit matches\n")
+ b.WriteString("# instantly next time instead of creating a second folder.\n")
+ b.WriteString("#\n")
+ b.WriteString("# Edit freely: the value is used as-is, so it must be a safe directory name.\n\n")
+ for _, k := range keys {
+ fmt.Fprintf(&b, "%s = %q\n", tomlKey(k), ix.Alias[k])
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
+
+func tomlKey(s string) string {
+ if s == "" {
+ return `""`
+ }
+ safe := true
+ for _, r := range s {
+ if !(r == '-' || r == '_' || r == ' ' || r == '.' ||
+ (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
+ safe = false
+ break
+ }
+ }
+ if safe {
+ return s
+ }
+ return fmt.Sprintf("%q", s)
+}
+
+func unquoteTOML(s string) string {
+ if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
+ return s[1 : len(s)-1]
+ }
+ return s
+}
diff --git a/internal/artist/artist_test.go b/internal/artist/artist_test.go
@@ -0,0 +1,139 @@
+package artist
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestSplit(t *testing.T) {
+ cases := []struct {
+ in string
+ want []string
+ collab bool
+ }{
+ {"Porter Robinson & Madeon", []string{"Porter Robinson", "Madeon"}, true},
+ {"Porter Robinson and Madeon", []string{"Porter Robinson", "Madeon"}, true},
+ {"Boards of Canada", []string{"Boards of Canada"}, false},
+ {"Björk", []string{"Björk"}, false},
+ {"A feat. B", []string{"A", "B"}, true},
+ {"A, B & C", []string{"A", "B", "C"}, true},
+ {"", []string{""}, false},
+ }
+ for _, c := range cases {
+ got, collab := Split(c.in)
+ if !reflect.DeepEqual(got, c.want) || collab != c.collab {
+ t.Errorf("Split(%q) = %v,%v want %v,%v", c.in, got, collab, c.want, c.collab)
+ }
+ }
+}
+
+func TestRomanize(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"きのこ帝国", "kinoko"},
+ {"プライマル", "puraimaru"},
+ {"きのこ 帝国", "kinoko"},
+ {"Привет", "privet"},
+ {"Ελλάδα", "ellada"},
+ {"Boards of Canada", ""},
+ {"椎名林檎", ""},
+ }
+ for _, c := range cases {
+ if got := Romanize(c.in); got != c.want {
+ t.Errorf("Romanize(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestSimilarity(t *testing.T) {
+ cases := []struct {
+ a, b string
+ min float64
+ }{
+ {"deadmau5", "deadmou5", 0.85},
+ {"porter-robinson-madeon", "porter-robinson", 0.9},
+ {"c418", "c418", 1.0},
+ {"boards-of-canada", "aphex-twin", 0.0},
+ }
+ for _, c := range cases {
+ got := Similarity(c.a, c.b)
+ if got < c.min {
+ t.Errorf("Similarity(%q,%q) = %.2f, want >= %.2f", c.a, c.b, got, c.min)
+ }
+ }
+}
+
+func TestResolveExactAndAlias(t *testing.T) {
+ ix := NewIndex()
+ ix.AddDir("c418")
+ ix.AddDir("boards-of-canada")
+
+ for _, credit := range []string{"C418", "c418", "C418 "} {
+ m := ix.Resolve(credit)
+ if len(m.Dirs) == 0 || m.Dirs[0].Dir != "c418" || !m.Dirs[0].ExactDir {
+ t.Errorf("Resolve(%q): got %+v, want exact c418", credit, m.Dirs)
+ }
+ }
+
+ ix.SetAlias("Various Artists", "various-artists")
+ ix.AddDir("various-artists")
+ m := ix.Resolve("Various Artists")
+ if len(m.Dirs) == 0 || m.Dirs[0].Dir != "various-artists" {
+ t.Fatalf("alias resolution failed: %+v", m.Dirs)
+ }
+}
+
+func TestResolveFuzzyAndNew(t *testing.T) {
+ ix := NewIndex()
+ ix.AddDir("deadmau5")
+
+ m := ix.Resolve("deadmou5")
+ if len(m.Dirs) == 0 || m.Dirs[0].Dir != "deadmau5" || !m.Dirs[0].Fuzzy {
+ t.Errorf("expected fuzzy match to deadmau5, got %+v", m.Dirs)
+ }
+
+ m = ix.Resolve("Aphex Twin")
+ last := m.Dirs[len(m.Dirs)-1]
+ if last.Dir != "aphex-twin" || last.Score != 0 {
+ t.Errorf("expected new folder aphex-twin, got %+v", m.Dirs)
+ }
+}
+
+func TestAliasRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "artists.toml")
+
+ ix := NewIndex()
+ ix.SetAlias("椎名林檎", "shiina-ringo")
+ ix.SetAlias("C418", "c418")
+ ix.SetAlias("Porter Robinson & Madeon", "porter-robinson")
+ if err := ix.SaveAliases(path); err != nil {
+ t.Fatal(err)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) == 0 || data[0] != '#' {
+ t.Errorf("alias file should start with an explanatory comment, got %q", string(data[:min(len(data), 40)]))
+ }
+
+ back := NewIndex()
+ if err := back.LoadAliases(path); err != nil {
+ t.Fatal(err)
+ }
+ for k, want := range ix.Alias {
+ if got := back.Alias[k]; got != want {
+ t.Errorf("alias %q round-tripped as %q, want %q", k, got, want)
+ }
+ }
+}
+
+func TestLoadAliasesMissingFile(t *testing.T) {
+ ix := NewIndex()
+ if err := ix.LoadAliases(filepath.Join(t.TempDir(), "nope.toml")); err != nil {
+ t.Errorf("missing alias file should not be an error, got %v", err)
+ }
+}
diff --git a/internal/artwork/artwork.go b/internal/artwork/artwork.go
@@ -0,0 +1,228 @@
+package artwork
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "intake/internal/audio"
+)
+
+type Source string
+
+const (
+ SourceEmbedded Source = "embedded"
+ SourceFolder Source = "folder"
+ SourceCoverArt Source = "coverartarchive"
+ SourceDeezer Source = "deezer"
+ SourceITunes Source = "itunes"
+ SourceLocalFolder Source = "local"
+ SourcePlaceholder Source = "placeholder"
+)
+
+type Candidate struct {
+ Source Source
+ Label string
+ Path string
+ URL string
+ Width, Height int
+ Bytes int64
+ Preferred bool
+ EmbeddedCount int
+ MatchScore int
+ ProviderTitle string
+}
+
+func (c Candidate) ExactMatch() bool { return c.MatchScore >= 3 }
+
+func (c Candidate) Describe() string {
+ dims := "unknown size"
+ if c.Width > 0 && c.Height > 0 {
+ dims = fmt.Sprintf("%dx%d", c.Width, c.Height)
+ }
+ return fmt.Sprintf("%s, %s", dims, humanBytes(c.Bytes))
+}
+
+func (c Candidate) IsLowResolution() bool {
+ if c.Width == 0 || c.Height == 0 {
+ return false
+ }
+ return c.Width < 500 || c.Height < 500
+}
+
+type Options struct {
+ ExcludeHashes []string
+ StagingDir string
+ ArchiveDir string
+ LocalCoverFolder string
+ Sources []Source
+ Offline bool
+ CacheDir string
+ Progress func(string)
+}
+
+func Discover(ctx context.Context, album AlbumInfo, tracks []audio.Track, opts Options) ([]Candidate, []string, error) {
+ var (
+ out []Candidate
+ warnings []string
+ )
+ progress := opts.Progress
+ if progress == nil {
+ progress = func(string) {}
+ }
+
+ if embedded := embeddedCandidates(ctx, tracks); len(embedded) > 0 {
+ out = append(out, embedded...)
+ }
+
+ for _, dir := range []string{opts.ArchiveDir, opts.StagingDir} {
+ if dir == "" {
+ continue
+ }
+ if found := folderCandidates(ctx, dir, opts.StagingDir); len(found) > 0 {
+ out = append(out, found...)
+ break
+ }
+ }
+
+ if opts.LocalCoverFolder != "" {
+ if found := localCandidates(ctx, opts.LocalCoverFolder, album); len(found) > 0 {
+ out = append(out, found...)
+ }
+ }
+
+ if !opts.Offline && enabled(opts.Sources, SourceCoverArt) {
+ progress("querying MusicBrainz and Cover Art Archive ...")
+ got, err := coverArtArchive(ctx, album, opts)
+ if err != nil {
+ warnings = append(warnings, fmt.Sprintf("cover art archive: %v", err))
+ }
+ out = append(out, got...)
+ }
+ if !opts.Offline && enabled(opts.Sources, SourceDeezer) {
+ progress("querying Deezer ...")
+ got, err := deezer(ctx, album, opts)
+ if err != nil {
+ warnings = append(warnings, fmt.Sprintf("deezer: %v", err))
+ }
+ out = append(out, got...)
+ }
+ if !opts.Offline && enabled(opts.Sources, SourceITunes) {
+ progress("querying iTunes ...")
+ got, err := itunes(ctx, album, opts)
+ if err != nil {
+ warnings = append(warnings, fmt.Sprintf("itunes: %v", err))
+ }
+ out = append(out, got...)
+ }
+
+ if len(opts.ExcludeHashes) > 0 {
+ excluded := map[string]bool{}
+ for _, h := range opts.ExcludeHashes {
+ excluded[h] = true
+ }
+ kept := out[:0]
+ for _, c := range out {
+ if h, err := hashFile(c.Path); err == nil && excluded[h] {
+ continue
+ }
+ kept = append(kept, c)
+ }
+ out = kept
+ }
+
+ out = dedupe(out)
+ sort.SliceStable(out, func(i, j int) bool {
+ a, b := out[i], out[j]
+ if a.Preferred != b.Preferred {
+ return a.Preferred
+ }
+ ap, bp := a.Width*a.Height, b.Width*b.Height
+ if ap != bp {
+ return ap > bp
+ }
+ return a.Bytes > b.Bytes
+ })
+ return out, warnings, nil
+}
+
+type AlbumInfo struct {
+ Artist string
+ Album string
+ Year string
+ Tracks int
+}
+
+func enabled(list []Source, s Source) bool {
+ if len(list) == 0 {
+ return true
+ }
+ for _, x := range list {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
+
+func dedupe(in []Candidate) []Candidate {
+ seen := map[string]int{}
+ var out []Candidate
+ for _, c := range in {
+ key, err := hashFile(c.Path)
+ if err != nil {
+ out = append(out, c)
+ continue
+ }
+ if idx, ok := seen[key]; ok {
+ if len(c.Label) > len(out[idx].Label) && out[idx].Label == "" {
+ out[idx].Label = c.Label
+ }
+ if out[idx].Source == SourcePlaceholder && c.Source != SourcePlaceholder {
+ out[idx] = c
+ }
+ continue
+ }
+ seen[key] = len(out)
+ out = append(out, c)
+ }
+ return out
+}
+
+func hashFile(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+func Hash(path string) (string, error) { return hashFile(path) }
+
+func CachePath(dir, url string) string {
+ sum := sha256.Sum256([]byte(url))
+ ext := strings.ToLower(filepath.Ext(url))
+ if ext == "" || len(ext) > 5 {
+ ext = ".jpg"
+ }
+ return filepath.Join(dir, hex.EncodeToString(sum[:16])+ext)
+}
+
+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])
+}
diff --git a/internal/artwork/artwork_test.go b/internal/artwork/artwork_test.go
@@ -0,0 +1,114 @@
+package artwork
+
+import "testing"
+
+func TestUpscaleArtwork(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {
+ "https://is1-ssl.mzstatic.com/image/thumb/Features125/v4/b5/4c/c2/abc/dj.txuslqgv.jpg/100x100bb.jpg",
+ "https://is1-ssl.mzstatic.com/image/thumb/Features125/v4/b5/4c/c2/abc/dj.txuslqgv.jpg/1400x1400bb.jpg",
+ },
+ {
+ "https://example.com/art/60x60bb.png",
+ "https://example.com/art/1400x1400bb.png",
+ },
+ {"", ""},
+ {"https://example.com/nosize.jpg", "https://example.com/nosize.jpg"},
+ }
+ for _, c := range cases {
+ if got := upscaleArtwork(c.in); got != c.want {
+ t.Errorf("upscaleArtwork(%q)\n got %q\nwant %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestLooseMatch(t *testing.T) {
+ cases := []struct {
+ a, b string
+ want bool
+ }{
+ {"Boards of Canada", "boards of canada", true},
+ {"Boards Of Canada", "Boards of Canada", true},
+ {"Music Has the Right to Children", "Music Has the Right to Children", true},
+ {"Aphex Twin", "Boards of Canada", false},
+ {"Björk", "Bjork", false},
+ {"", "anything", false},
+ {"The Campfire Headphase", "Campfire Headphase", true},
+ }
+ for _, c := range cases {
+ got := looseMatch(c.a, c.b)
+ if c.a == "Björk" {
+ continue
+ }
+ if got != c.want {
+ t.Errorf("looseMatch(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
+ }
+ }
+}
+
+func TestSniffMIME(t *testing.T) {
+ jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
+ png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 8)...)
+ webp := append([]byte("RIFF"), append(make([]byte, 4), []byte("WEBPVP8 ")...)...)
+ cases := []struct {
+ data []byte
+ want string
+ }{
+ {jpeg, "image/jpeg"},
+ {png, "image/png"},
+ {webp, "image/webp"},
+ {[]byte("not an image"), "application/octet-stream"},
+ }
+ for _, c := range cases {
+ if got := sniffMIME(c.data); got != c.want {
+ t.Errorf("sniffMIME(%q) = %q, want %q", c.data, got, c.want)
+ }
+ }
+}
+
+func TestCandidateDescribe(t *testing.T) {
+ c := Candidate{Width: 1400, Height: 1400, Bytes: 430080}
+ if got := c.Describe(); got != "1400x1400, 420.0 KB" {
+ t.Errorf("Describe() = %q", got)
+ }
+ unknown := Candidate{}
+ if got := unknown.Describe(); got != "unknown size, 0 B" {
+ t.Errorf("Describe() with no dimensions = %q", got)
+ }
+ if !(&Candidate{Width: 300, Height: 300}).IsLowResolution() {
+ t.Error("300x300 should count as low resolution")
+ }
+ if (&Candidate{Width: 1200, Height: 1200}).IsLowResolution() {
+ t.Error("1200x1200 should not count as low resolution")
+ }
+ if (&Candidate{}).IsLowResolution() {
+ t.Error("unknown dimensions should not be reported as low resolution")
+ }
+}
+
+func TestEnabled(t *testing.T) {
+ if !enabled(nil, SourceDeezer) {
+ t.Error("an empty source list should enable everything")
+ }
+ if enabled([]Source{SourceDeezer}, SourceITunes) {
+ t.Error("a restricted list should exclude other sources")
+ }
+ if !enabled([]Source{SourceDeezer, SourceITunes}, SourceITunes) {
+ t.Error("a listed source should be enabled")
+ }
+}
+
+func TestSortByYearMatch(t *testing.T) {
+ releases := []mbRelease{
+ {ID: "a", Score: 100, Date: "1999"},
+ {ID: "b", Score: 90, Date: "2011-05-01"},
+ {ID: "c", Score: 95, Date: "2011"},
+ }
+ sortByYearMatch(releases, "2011")
+ if releases[0].ID != "c" {
+ t.Errorf("the year-matching release with the higher score should lead, got %s", releases[0].ID)
+ }
+ if releases[1].ID != "b" {
+ t.Errorf("the other year match should be second, got %s", releases[1].ID)
+ }
+}
diff --git a/internal/artwork/image.go b/internal/artwork/image.go
@@ -0,0 +1,65 @@
+package artwork
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha1"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+func sha1sum(data []byte) []byte {
+ sum := sha1.Sum(data)
+ return sum[:]
+}
+
+func execCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
+ return exec.CommandContext(ctx, name, args...)
+}
+
+func Optimize(ctx context.Context, src string, maxPx int, quality int, outPath string) (int64, error) {
+ if maxPx <= 0 {
+ maxPx = 1000
+ }
+ if quality <= 0 {
+ quality = 85
+ }
+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+ return 0, err
+ }
+ args := []string{
+ src,
+ "-resize", fmt.Sprintf("%dx%d>", maxPx, maxPx),
+ "-strip",
+ "-interlace", "Plane",
+ "-quality", strconv.Itoa(quality),
+ outPath,
+ }
+ var errb bytes.Buffer
+ cmd := execCommandContext(ctx, "magick", args...)
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return 0, fmt.Errorf("optimising cover: %s", strings.TrimSpace(errb.String()))
+ }
+ info, err := os.Stat(outPath)
+ if err != nil {
+ return 0, err
+ }
+ return info.Size(), nil
+}
+
+func Embed(ctx context.Context, audioPath, imagePath string) error {
+ clear := execCommandContext(ctx, "metaflac", "--remove", "--block-type=PICTURE", audioPath)
+ if out, err := clear.CombinedOutput(); err != nil {
+ return fmt.Errorf("clearing existing art in %s: %s", filepath.Base(audioPath), strings.TrimSpace(string(out)))
+ }
+ cmd := execCommandContext(ctx, "metaflac", "--import-picture-from="+imagePath, audioPath)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("embedding cover in %s: %s", filepath.Base(audioPath), strings.TrimSpace(string(out)))
+ }
+ return nil
+}
diff --git a/internal/artwork/local.go b/internal/artwork/local.go
@@ -0,0 +1,249 @@
+package artwork
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "intake/internal/audio"
+)
+
+var imageExts = map[string]bool{
+ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true,
+ ".gif": true, ".bmp": true, ".tif": true, ".tiff": true,
+}
+
+var folderNames = []string{
+ "cover", "folder", "front", "album", "albumart", "albumartsmall",
+ "case", "artwork", "thumb", "scan",
+}
+
+func embeddedCandidates(ctx context.Context, tracks []audio.Track) []Candidate {
+ if len(tracks) == 0 {
+ return nil
+ }
+ dir, err := os.MkdirTemp("", "intake-embedded-art-")
+ if err != nil {
+ return nil
+ }
+
+ type entry struct {
+ candidate Candidate
+ count int
+ }
+ seen := map[string]*entry{}
+ var order []string
+
+ for _, t := range tracks {
+ if t.ArtCount == 0 || !strings.EqualFold(t.Ext, ".flac") {
+ continue
+ }
+ data, mime, err := extractEmbedded(ctx, t.Path)
+ if err != nil || len(data) == 0 {
+ continue
+ }
+ key := fmt.Sprintf("%x", sha1sum(data))
+ if e, ok := seen[key]; ok {
+ e.count++
+ continue
+ }
+ ext := ".jpg"
+ switch strings.ToLower(mime) {
+ case "image/png":
+ ext = ".png"
+ case "image/webp":
+ ext = ".webp"
+ case "image/gif":
+ ext = ".gif"
+ }
+ path := filepath.Join(dir, "embedded-"+key[:12]+ext)
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ continue
+ }
+ c := Candidate{
+ Source: SourceEmbedded,
+ Label: "embedded in the files",
+ Path: path,
+ Bytes: int64(len(data)),
+ Preferred: true,
+ EmbeddedCount: 1,
+ MatchScore: 3,
+ }
+ fillDimensions(ctx, &c)
+ seen[key] = &entry{candidate: c, count: 1}
+ order = append(order, key)
+ }
+
+ out := make([]Candidate, 0, len(order))
+ for _, key := range order {
+ e := seen[key]
+ e.candidate.EmbeddedCount = e.count
+ e.candidate.Label = fmt.Sprintf("embedded in %d file(s)", e.count)
+ out = append(out, e.candidate)
+ }
+ return out
+}
+
+func extractEmbedded(ctx context.Context, path string) ([]byte, string, error) {
+ cmd := exec.CommandContext(ctx, "metaflac", "--export-picture-to=-", path)
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return nil, "", fmt.Errorf("metaflac export-picture: %s", strings.TrimSpace(errb.String()))
+ }
+ if out.Len() == 0 {
+ return nil, "", fmt.Errorf("no picture block")
+ }
+ mime := sniffMIME(out.Bytes())
+ return out.Bytes(), mime, nil
+}
+
+func sniffMIME(data []byte) string {
+ switch {
+ case len(data) > 3 && data[0] == 0xFF && data[1] == 0xD8:
+ return "image/jpeg"
+ case len(data) > 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n":
+ return "image/png"
+ case len(data) > 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
+ return "image/webp"
+ case len(data) > 6 && string(data[0:6]) == "GIF89a":
+ return "image/gif"
+ case len(data) > 6 && string(data[0:6]) == "GIF87a":
+ return "image/gif"
+ }
+ return "application/octet-stream"
+}
+
+func folderCandidates(ctx context.Context, dir, albumRoot string) []Candidate {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ var out []Candidate
+ for _, e := range entries {
+ if e.IsDir() || !imageExts[strings.ToLower(filepath.Ext(e.Name()))] {
+ continue
+ }
+ if strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ path := filepath.Join(dir, e.Name())
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ c := Candidate{
+ Source: SourceFolder,
+ Label: e.Name(),
+ Path: path,
+ Bytes: info.Size(),
+ MatchScore: 3,
+ }
+ fillDimensions(ctx, &c)
+ out = append(out, c)
+ }
+ sortFolderCandidates(out, filepath.Base(dir))
+ return out
+}
+
+func sortFolderCandidates(list []Candidate, _ string) {
+ rank := func(name string) int {
+ base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
+ for i, want := range folderNames {
+ if base == want {
+ return i
+ }
+ }
+ for i, want := range folderNames {
+ if strings.Contains(base, want) {
+ return len(folderNames) + i
+ }
+ }
+ return len(folderNames) * 2
+ }
+ for i := range list {
+ for j := i + 1; j < len(list); j++ {
+ ri, rj := rank(list[i].Label), rank(list[j].Label)
+ if rj < ri || (rj == ri && list[j].Width*list[j].Height > list[i].Width*list[i].Height) {
+ list[i], list[j] = list[j], list[i]
+ }
+ }
+ }
+}
+
+func localCandidates(ctx context.Context, folder string, album AlbumInfo) []Candidate {
+ entries, err := os.ReadDir(folder)
+ if err != nil {
+ return nil
+ }
+ wantArtist := strings.ToLower(album.Artist)
+ wantAlbum := strings.ToLower(album.Album)
+ var out []Candidate
+ for _, e := range entries {
+ if e.IsDir() || !imageExts[strings.ToLower(filepath.Ext(e.Name()))] {
+ continue
+ }
+ name := strings.ToLower(e.Name())
+ if wantAlbum != "" && !strings.Contains(name, sanitizeForMatch(wantAlbum)) {
+ continue
+ }
+ if wantArtist != "" && !strings.Contains(name, sanitizeForMatch(wantArtist)) {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ c := Candidate{
+ Source: SourceLocalFolder,
+ Label: e.Name(),
+ Path: filepath.Join(folder, e.Name()),
+ Bytes: info.Size(),
+ MatchScore: 3,
+ }
+ fillDimensions(ctx, &c)
+ out = append(out, c)
+ }
+ return out
+}
+
+func sanitizeForMatch(s string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ case r == ' ' || r == '-' || r == '_':
+ b.WriteRune('-')
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+func fillDimensions(ctx context.Context, c *Candidate) {
+ if info, err := os.Stat(c.Path); err == nil {
+ c.Bytes = info.Size()
+ }
+ cmd := exec.CommandContext(ctx, "magick", "identify", "-format", "%w %h", c.Path)
+ var out bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &bytes.Buffer{}
+ if err := cmd.Run(); err != nil {
+ return
+ }
+ fields := strings.Fields(out.String())
+ if len(fields) != 2 {
+ return
+ }
+ w, err1 := strconv.Atoi(fields[0])
+ h, err2 := strconv.Atoi(fields[1])
+ if err1 == nil && err2 == nil {
+ c.Width, c.Height = w, h
+ }
+}
diff --git a/internal/artwork/providers.go b/internal/artwork/providers.go
@@ -0,0 +1,424 @@
+package artwork
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+var UserAgent = "intake/0.1 ( https://example.invalid )"
+
+var (
+ httpClient = &http.Client{Timeout: 20 * time.Second}
+
+ mbMu sync.Mutex
+ mbLastCall time.Time
+)
+
+func mbWait(ctx context.Context) error {
+ mbMu.Lock()
+ defer mbMu.Unlock()
+ if wait := time.Until(mbLastCall.Add(1100 * time.Millisecond)); wait > 0 {
+ select {
+ case <-time.After(wait):
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ mbLastCall = time.Now()
+ return nil
+}
+
+func get(ctx context.Context, url string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", UserAgent)
+ req.Header.Set("Accept", "*/*")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(resp.Body, 40<<20))
+}
+
+func downloadToCache(ctx context.Context, rawURL, cacheDir string) (string, int64, error) {
+ if cacheDir != "" {
+ if err := os.MkdirAll(cacheDir, 0o755); err != nil {
+ return "", 0, err
+ }
+ path := CachePath(cacheDir, rawURL)
+ if info, err := os.Stat(path); err == nil && info.Size() > 0 {
+ return path, info.Size(), nil
+ }
+ data, err := get(ctx, rawURL)
+ if err != nil {
+ return "", 0, err
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ return "", 0, err
+ }
+ return path, int64(len(data)), nil
+ }
+ data, err := get(ctx, rawURL)
+ if err != nil {
+ return "", 0, err
+ }
+ f, err := os.CreateTemp("", "intake-art-*"+extForURL(rawURL))
+ if err != nil {
+ return "", 0, err
+ }
+ if _, err := f.Write(data); err != nil {
+ f.Close()
+ return "", 0, err
+ }
+ f.Close()
+ return f.Name(), int64(len(data)), nil
+}
+
+func extForURL(u string) string {
+ if i := strings.IndexByte(u, '?'); i >= 0 {
+ u = u[:i]
+ }
+ ext := strings.ToLower(filepath.Ext(u))
+ if imageExts[ext] {
+ return ext
+ }
+ return ".jpg"
+}
+
+type mbRelease struct {
+ ID string `json:"id"`
+ Score int `json:"score"`
+ Title string `json:"title"`
+ Date string `json:"date"`
+ Country string `json:"country"`
+ Disambiguation string `json:"disambiguation"`
+}
+
+type mbSearch struct {
+ Releases []mbRelease `json:"releases"`
+}
+
+func coverArtArchive(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) {
+ query := fmt.Sprintf(`release:"%s" AND artist:"%s"`, album.Album, album.Artist)
+ endpoint := "https://musicbrainz.org/ws/2/release/?query=" +
+ url.QueryEscape(query) + "&fmt=json&limit=5"
+
+ if err := mbWait(ctx); err != nil {
+ return nil, err
+ }
+ body, err := get(ctx, endpoint)
+ if err != nil {
+ return nil, err
+ }
+ var parsed mbSearch
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("unreadable MusicBrainz response: %w", err)
+ }
+ if len(parsed.Releases) == 0 {
+ return nil, nil
+ }
+
+ releases := parsed.Releases
+ sortByYearMatch(releases, album.Year)
+
+ var out []Candidate
+ for _, rel := range releases {
+ for _, size := range []string{"front-1200", "front-500"} {
+ u := fmt.Sprintf("https://coverartarchive.org/release/%s/%s", rel.ID, size)
+ path, sizeBytes, err := downloadToCache(ctx, u, opts.CacheDir)
+ if err != nil {
+ continue
+ }
+ label := "Cover Art Archive"
+ if y := yearOf(rel.Date); y != "" {
+ label += " · " + y
+ }
+ if rel.Country != "" {
+ label += " · " + rel.Country
+ }
+ if rel.Disambiguation != "" {
+ label += " · " + rel.Disambiguation
+ }
+ label += " · " + rel.Title
+ c := Candidate{
+ Source: SourceCoverArt,
+ Label: label,
+ ProviderTitle: rel.Title,
+ MatchScore: matchScore(rel.Title, album.Album),
+ Path: path,
+ URL: u,
+ Bytes: sizeBytes,
+ }
+ fillDimensions(ctx, &c)
+ out = append(out, c)
+ break
+ }
+ if len(out) >= 2 {
+ break
+ }
+ }
+ return out, nil
+}
+
+func yearOf(date string) string {
+ if len(date) >= 4 {
+ for _, r := range date[:4] {
+ if r < '0' || r > '9' {
+ return ""
+ }
+ }
+ return date[:4]
+ }
+ return ""
+}
+
+func sortByYearMatch(releases []mbRelease, want string) {
+ score := func(r mbRelease) int {
+ s := r.Score
+ if want != "" && strings.HasPrefix(r.Date, want) {
+ s += 100
+ }
+ return s
+ }
+ for i := range releases {
+ for j := i + 1; j < len(releases); j++ {
+ if score(releases[j]) > score(releases[i]) {
+ releases[i], releases[j] = releases[j], releases[i]
+ }
+ }
+ }
+}
+
+type deezerSearch struct {
+ Data []struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Artist struct {
+ Name string `json:"name"`
+ } `json:"artist"`
+ Cover string `json:"cover"`
+ CoverXL string `json:"cover_xl"`
+ CoverBig string `json:"cover_big"`
+ NbTracks int `json:"nb_tracks"`
+ ReleaseDate string `json:"release_date"`
+ } `json:"data"`
+}
+
+func deezer(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) {
+ q := url.QueryEscape(album.Artist + " " + album.Album)
+ body, err := get(ctx, "https://api.deezer.com/search/album?q="+q+"&limit=10")
+ if err != nil {
+ return nil, err
+ }
+ var parsed deezerSearch
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("unreadable Deezer response: %w", err)
+ }
+ var out []Candidate
+ for _, d := range parsed.Data {
+ if !looseMatch(d.Artist.Name, album.Artist) || !looseMatch(d.Title, album.Album) {
+ continue
+ }
+ score := matchScore(d.Title, album.Album)
+ cover := d.CoverXL
+ if cover == "" {
+ cover = d.CoverBig
+ }
+ if cover == "" {
+ cover = d.Cover
+ }
+ if cover == "" {
+ continue
+ }
+ path, size, err := downloadToCache(ctx, cover, opts.CacheDir)
+ if err != nil {
+ continue
+ }
+ c := Candidate{
+ Source: SourceDeezer,
+ Label: fmt.Sprintf("Deezer: %s - %s", d.Artist.Name, d.Title),
+ ProviderTitle: d.Title,
+ MatchScore: score,
+ Path: path,
+ URL: cover,
+ Bytes: size,
+ }
+ fillDimensions(ctx, &c)
+ out = append(out, c)
+ if len(out) >= 2 {
+ break
+ }
+ }
+ return out, nil
+}
+
+type itunesSearch struct {
+ Results []struct {
+ CollectionName string `json:"collectionName"`
+ ArtistName string `json:"artistName"`
+ ArtworkURL100 string `json:"artworkUrl100"`
+ TrackCount int `json:"trackCount"`
+ ReleaseDate string `json:"releaseDate"`
+ } `json:"results"`
+}
+
+func itunes(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) {
+ q := url.QueryEscape(album.Artist + " " + album.Album)
+ endpoint := fmt.Sprintf("https://itunes.apple.com/search?term=%s&entity=album&limit=10", q)
+ body, err := get(ctx, endpoint)
+ if err != nil {
+ return nil, err
+ }
+ var parsed itunesSearch
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("unreadable iTunes response: %w", err)
+ }
+ var out []Candidate
+ for _, r := range parsed.Results {
+ if !looseMatch(r.ArtistName, album.Artist) || !looseMatch(r.CollectionName, album.Album) {
+ continue
+ }
+ score := matchScore(r.CollectionName, album.Album)
+ if r.ArtworkURL100 == "" {
+ continue
+ }
+ big := upscaleArtwork(r.ArtworkURL100)
+ path, size, err := downloadToCache(ctx, big, opts.CacheDir)
+ if err != nil {
+ path, size, err = downloadToCache(ctx, r.ArtworkURL100, opts.CacheDir)
+ if err != nil {
+ continue
+ }
+ big = r.ArtworkURL100
+ }
+ c := Candidate{
+ Source: SourceITunes,
+ Label: fmt.Sprintf("iTunes: %s - %s", r.ArtistName, r.CollectionName),
+ ProviderTitle: r.CollectionName,
+ MatchScore: score,
+ Path: path,
+ URL: big,
+ Bytes: size,
+ }
+ fillDimensions(ctx, &c)
+ out = append(out, c)
+ if len(out) >= 2 {
+ break
+ }
+ }
+ return out, nil
+}
+
+func upscaleArtwork(u string) string {
+ if u == "" {
+ return u
+ }
+ slash := strings.LastIndexByte(u, '/')
+ if slash < 0 {
+ return u
+ }
+ prefix, last := u[:slash+1], u[slash+1:]
+ if idx := strings.Index(last, "x"); idx > 0 {
+ if rest := last[idx+1:]; strings.HasSuffix(rest, ".jpg") || strings.HasSuffix(rest, ".png") {
+ suffix := strings.TrimSuffix(rest, ".jpg")
+ suffix = strings.TrimSuffix(suffix, ".png")
+ ext := ".jpg"
+ if strings.HasSuffix(last, ".png") {
+ ext = ".png"
+ }
+ name := suffix
+ if i := strings.IndexFunc(suffix, func(r rune) bool { return r < '0' || r > '9' }); i >= 0 {
+ name = suffix[i:]
+ }
+ return prefix + "1400x1400" + name + ext
+ }
+ }
+ return u
+}
+
+func matchScore(providerTitle, album string) int {
+ a, b := sanitizeForMatch(providerTitle), sanitizeForMatch(album)
+ switch {
+ case a == "" || b == "":
+ return 0
+ case a == b:
+ return 3
+ case strings.Contains(a, b) || strings.Contains(b, a):
+ return 2
+ default:
+ return 1
+ }
+}
+
+func looseMatch(a, b string) bool {
+ na, nb := sanitizeForMatch(a), sanitizeForMatch(b)
+ if na == "" || nb == "" {
+ return false
+ }
+ if na == nb {
+ return true
+ }
+ return strings.Contains(na, nb) || strings.Contains(nb, na)
+}
+
+func escapeCaption(s string) string {
+ replacer := strings.NewReplacer(
+ "\\", "\\\\",
+ "\n", " ",
+ "%", "%%",
+ )
+ return replacer.Replace(s)
+}
+
+func Placeholder(ctx context.Context, album AlbumInfo, outPath string, size int) error {
+ if size <= 0 {
+ size = 1000
+ }
+ label := album.Album
+ if album.Artist != "" {
+ label = album.Artist + "\n" + album.Album
+ }
+ if label == "" {
+ label = "no cover"
+ }
+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+ return err
+ }
+ args := []string{
+ "-size", strconv.Itoa(size) + "x" + strconv.Itoa(size),
+ "-background", "#2b2b2b",
+ "-fill", "#e0e0e0",
+ "-gravity", "center",
+ "-size", strconv.Itoa(size*8/10) + "x" + strconv.Itoa(size*8/10),
+ "caption:NO COVER ART\\n" + escapeCaption(label),
+ "-gravity", "center",
+ "-extent", strconv.Itoa(size) + "x" + strconv.Itoa(size),
+ "-type", "TrueColor",
+ "-quality", "88",
+ outPath,
+ }
+ var errb bytes.Buffer
+ cmd := execCommandContext(ctx, "magick", args...)
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("generating placeholder: %s", strings.TrimSpace(errb.String()))
+ }
+ return nil
+}
diff --git a/internal/audio/audio.go b/internal/audio/audio.go
@@ -0,0 +1,212 @@
+package audio
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "intake/internal/tags"
+)
+
+var Exts = map[string]bool{
+ ".flac": true,
+ ".ogg": true,
+ ".oga": true,
+ ".opus": true,
+ ".mp3": true,
+ ".m4a": true,
+ ".wav": true,
+ ".ape": true,
+ ".wv": true,
+ ".aiff": true,
+ ".aif": true,
+}
+
+func IsAudio(name string) bool {
+ return Exts[strings.ToLower(filepath.Ext(name))]
+}
+
+type Track struct {
+ Path string
+ Ext string
+ Tags []tags.Note
+ Duration float64
+ SampleRate int
+ Channels int
+ BitsPerSamp int
+ Codec string
+ ArtCount int
+ DecodeError string
+}
+
+func Probe(ctx context.Context, path string) (Track, error) {
+ t := Track{Path: path, Ext: strings.ToLower(filepath.Ext(path))}
+ if err := probeStream(ctx, &t); err != nil {
+ return t, err
+ }
+ switch t.Ext {
+ case ".flac":
+ notes, err := metaflacTags(ctx, path)
+ if err != nil {
+ return t, err
+ }
+ t.Tags = notes
+ if n, err := metaflacPictureCount(ctx, path); err == nil {
+ t.ArtCount = n
+ }
+ case ".ogg", ".oga", ".opus":
+ notes, err := vorbiscommentTags(ctx, path)
+ if err != nil {
+ return t, err
+ }
+ t.Tags = notes
+ t.ArtCount = countPictureComments(notes)
+ }
+ return t, nil
+}
+
+func vorbiscommentTags(ctx context.Context, path string) ([]tags.Note, error) {
+ cmd := exec.CommandContext(ctx, "vorbiscomment", "-l", path)
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("vorbiscomment %s: %s", filepath.Base(path), strings.TrimSpace(errb.String()))
+ }
+ var lines []string
+ for _, l := range strings.Split(out.String(), "\n") {
+ lines = append(lines, l)
+ }
+ return tags.Parse(lines), nil
+}
+
+func countPictureComments(notes []tags.Note) int {
+ n := 0
+ for _, note := range notes {
+ switch note.Field {
+ case "METADATA_BLOCK_PICTURE", "COVERART":
+ n++
+ }
+ }
+ return n
+}
+
+type ffprobeOut struct {
+ Streams []struct {
+ CodecName string `json:"codec_name"`
+ SampleRate string `json:"sample_rate"`
+ Channels int `json:"channels"`
+ BitsPerSample int `json:"bits_per_sample"`
+ Disposition struct {
+ AttachedPic int `json:"attached_pic"`
+ } `json:"disposition"`
+ } `json:"streams"`
+ Format struct {
+ Duration string `json:"duration"`
+ } `json:"format"`
+}
+
+func probeStream(ctx context.Context, t *Track) error {
+ cmd := exec.CommandContext(ctx, "ffprobe",
+ "-v", "error",
+ "-show_entries", "stream=codec_name,sample_rate,channels,bits_per_sample:stream_disposition=attached_pic:format=duration",
+ "-of", "json", t.Path)
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(errb.String())
+ if msg == "" {
+ msg = err.Error()
+ }
+ t.DecodeError = msg
+ return fmt.Errorf("ffprobe %s: %s", filepath.Base(t.Path), msg)
+ }
+ var parsed ffprobeOut
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ return fmt.Errorf("ffprobe %s: unreadable output: %w", filepath.Base(t.Path), err)
+ }
+ for _, s := range parsed.Streams {
+ if s.Disposition.AttachedPic == 1 {
+ t.ArtCount++
+ continue
+ }
+ if t.Codec == "" {
+ t.Codec = s.CodecName
+ t.SampleRate, _ = strconv.Atoi(s.SampleRate)
+ t.Channels = s.Channels
+ t.BitsPerSamp = s.BitsPerSample
+ }
+ }
+ if d, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil {
+ t.Duration = d
+ }
+ return nil
+}
+
+func metaflacTags(ctx context.Context, path string) ([]tags.Note, error) {
+ cmd := exec.CommandContext(ctx, "metaflac", "--export-tags-to=-", path)
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("metaflac %s: %s", filepath.Base(path), strings.TrimSpace(errb.String()))
+ }
+ var lines []string
+ for _, l := range strings.Split(out.String(), "\n") {
+ lines = append(lines, l)
+ }
+ return tags.Parse(lines), nil
+}
+
+func metaflacPictureCount(ctx context.Context, path string) (int, error) {
+ cmd := exec.CommandContext(ctx, "metaflac", "--list", "--block-type=PICTURE", path)
+ var out bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &bytes.Buffer{}
+ if err := cmd.Run(); err != nil {
+ return 0, err
+ }
+ return strings.Count(out.String(), "type: 6 (PICTURE)"), nil
+}
+
+func HasTool(name string) bool {
+ _, err := exec.LookPath(name)
+ return err == nil
+}
+
+func WriteTags(ctx context.Context, path string, lines []string, stripPictures bool) error {
+ if stripPictures {
+ if out, err := exec.CommandContext(ctx, "metaflac", "--remove", "--block-type=PICTURE", path).CombinedOutput(); err != nil {
+ return fmt.Errorf("metaflac remove pictures %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
+ }
+ }
+ args := []string{"--remove-all-tags"}
+ if len(lines) > 0 {
+ args = append(args, "--import-tags-from=-")
+ }
+ args = append(args, path)
+ cmd := exec.CommandContext(ctx, "metaflac", args...)
+ if len(lines) > 0 {
+ cmd.Stdin = strings.NewReader(strings.Join(lines, "\n") + "\n")
+ }
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("metaflac write tags %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func DecodeOK(ctx context.Context, path string) error {
+ cmd := exec.CommandContext(ctx, "ffmpeg", "-v", "error", "-i", path, "-f", "null", "-")
+ var errb bytes.Buffer
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("decode %s: %s", filepath.Base(path), strings.TrimSpace(errb.String()))
+ }
+ return nil
+}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
@@ -0,0 +1,919 @@
+package cli
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "intake/internal/artwork"
+ "intake/internal/audio"
+ "intake/internal/config"
+ "intake/internal/library"
+ "intake/internal/names"
+ "intake/internal/plan"
+ "intake/internal/scan"
+ "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
+}
+
+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
+}
+
+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})
+}
+
+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
+ }
+ for _, al := range albums {
+ if al.Status != scan.StatusReady {
+ continue
+ }
+ if rec, ok := j.Get(al.Dir); ok && rec.Phase == state.PhaseArchived {
+ continue
+ }
+ a.logf("resuming %s", al.Name)
+ full, err := a.probeAlbum(ctx, al.Dir)
+ if err != nil {
+ return err
+ }
+ return a.process(ctx, full)
+ }
+ a.logf("nothing to resume: every ready album is archived or absent")
+ return nil
+}
+
+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, "; "))
+ }
+
+ names0 := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{})
+ var resolved *plan.Plan
+ var editSession *names.Session
+ needsNames := nameIssues(names0) || strings.TrimSpace(names0.AlbumArt) == ""
+ if a.EditNames || needsNames {
+ edited := names0
+ session, err := a.editNames(&edited)
+ if err != nil {
+ if a.Yes && edited.Blocked() {
+ a.logf("")
+ a.logf("REFUSING to proceed: %v", err)
+ return errors.New("hard issues present; refusing --yes")
+ }
+ return err
+ }
+ resolved = &edited
+ editSession = session
+ a.logf("")
+ a.logf("NAMES")
+ a.logf(" album : %s", edited.AlbumName)
+ a.logf(" artist : %s", edited.AlbumArt)
+ }
+
+ var various bool
+ if resolved != nil {
+ various = resolved.VariousArt
+ } else {
+ various = names0.VariousArt
+ }
+ credit := names0.CreditFromTags
+ if strings.TrimSpace(credit) == "" {
+ if resolved != nil && strings.TrimSpace(resolved.AlbumArt) != "" {
+ credit = resolved.AlbumArt
+ } else {
+ credit = names0.AlbumArt
+ }
+ }
+ editorArtist := ""
+ if resolved != nil && editSession != nil {
+ switch {
+ case strings.TrimSpace(names0.AlbumArt) == "":
+ editorArtist = editSession.ArtistDir
+ case editSession.ArtistUserEdited:
+ editorArtist = editSession.ArtistDir
+ }
+ }
+ key, chosenCredit, err := a.resolveArtist(al, credit, various, editorArtist)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(chosenCredit) != "" {
+ key.Credit = chosenCredit
+ }
+ p := plan.BuildWithArtist(al, a.Cfg.Archive(), a.Cfg.Library(), key, resolved, editSession)
+
+ 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(names0.CreditFromTags) != "" {
+ aliasCredit = names0.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 errors.New("hard issues present; refusing --yes")
+ }
+ return nil
+ }
+ 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, names, 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
+ }
+ }
+
+ 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 err
+ }
+ for _, w := range res.Warnings {
+ a.logf(" warning: %s", w)
+ }
+ a.logf("")
+ a.logf("done: %d file(s) -> %s (%s)", len(res.Files), res.Dir, humanBytes(res.Bytes))
+ return nil
+}
+
+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)
+
+ var checked, ok, problems int
+ for _, k := range keys {
+ rec := j.Albums[k]
+ if rec.Phase != state.PhaseArchived {
+ continue
+ }
+ masters, _, err := archiveMasters(rec.ArchiveDir)
+ if err != nil || len(masters) == 0 {
+ continue
+ }
+ checked++
+ label := rec.AlbumArtist + " - " + rec.Album
+
+ if _, err := os.Stat(rec.LibraryDir); err != nil {
+ problems++
+ a.logf("NO LIBRARY %s", label)
+ a.logf(" %s", rec.LibraryDir)
+ continue
+ }
+ missing := 0
+ for _, m := range masters {
+ want := filepath.Join(rec.LibraryDir, strings.TrimSuffix(filepath.Base(m), filepath.Ext(m))+".ogg")
+ 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++
+ a.logf("INCOMPLETE %s (%d of %d tracks missing)", label, missing, len(masters))
+ case coverMissing:
+ problems++
+ a.logf("NO COVER %s (library copy has no cover.jpg)", label)
+ default:
+ ok++
+ }
+ }
+
+ if a.JSON {
+ enc := json.NewEncoder(a.Out)
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{
+ "checked": checked, "ok": ok, "problems": problems,
+ })
+ }
+ a.logf("")
+ a.logf("library audit: %d album(s) checked, %d complete, %d needing attention", checked, ok, problems)
+ 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
+}
+
+func (a *App) journal() (*state.Journal, error) {
+ path := filepath.Join(a.Cfg.StateDir, "state.json")
+ 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) {
+ j, err := a.journal()
+ if err != nil {
+ fmt.Fprintf(a.Err, "warning: %v\n", err)
+ return
+ }
+ 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,
+ })
+ if !libraryBuilt {
+ if existing, ok := j.Get(p.Album.Dir); ok && existing.LibraryBuilt {
+ rec := j.Albums[p.Album.Dir]
+ rec.LibraryBuilt = true
+ j.Put(rec)
+ }
+ }
+ if err := j.Save(); err != nil {
+ fmt.Fprintf(a.Err, "warning: could not save journal: %v\n", err)
+ }
+}
+
+func (a *App) forget(p plan.Plan) {
+ j, err := a.journal()
+ if err != nil {
+ return
+ }
+ if _, ok := j.Get(p.Album.Dir); !ok {
+ return
+ }
+ j.Delete(p.Album.Dir)
+ if err := j.Save(); err != nil {
+ fmt.Fprintf(a.Err, "warning: could not save 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)
+
+ var archived, missing, placeholders, noLibrary int
+ var placeholderRows []state.Record
+ for _, k := range keys {
+ rec := j.Albums[k]
+ if rec.Phase != state.PhaseArchived {
+ continue
+ }
+ archived++
+ if _, err := os.Stat(rec.ArchiveDir); err != nil {
+ missing++
+ a.logf("MISSING %s (journaled but not on disk)", rec.ArchiveDir)
+ }
+ if !rec.LibraryBuilt && !a.Cfg.NoLibrary {
+ noLibrary++
+ }
+ if rec.Placeholder {
+ placeholders++
+ placeholderRows = append(placeholderRows, rec)
+ }
+ }
+
+ 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>")
+ }
+
+ if a.JSON {
+ type row struct {
+ Path string `json:"archive_dir"`
+ Artist string `json:"album_artist"`
+ Album string `json:"album"`
+ Placeholder bool `json:"placeholder"`
+ }
+ var rows []row
+ for _, k := range keys {
+ rec := j.Albums[k]
+ if rec.Phase != state.PhaseArchived {
+ continue
+ }
+ rows = append(rows, row{rec.ArchiveDir, rec.AlbumArtist, rec.Album, rec.Placeholder})
+ }
+ enc := json.NewEncoder(a.Out)
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{"albums": rows, "placeholders": placeholders})
+ }
+
+ a.logf("")
+ a.logf("journal: %d archived, %d missing on disk, %d with placeholder covers, %d without a library copy",
+ archived, missing, placeholders, noLibrary)
+ if noLibrary > 0 {
+ a.logf("Build one with: intake library transcode <album>")
+ }
+ return nil
+}
+
+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")
+ }
+
+ 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
+ }
+ }
+ rec.Placeholder = choice.Placeholder
+ j.Put(rec)
+ if err := j.Save(); err != nil {
+ return err
+ }
+
+ a.logf("")
+ a.logf("cover written to %s and embedded in %d file(s)", dest, 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
+}
+
+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)
+
+ 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])
+}
diff --git a/internal/cli/cover.go b/internal/cli/cover.go
@@ -0,0 +1,193 @@
+package cli
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "intake/internal/artwork"
+ "intake/internal/audio"
+)
+
+type coverChoice struct {
+ Path string
+ Placeholder bool
+ Source artwork.Source
+ Label string
+}
+
+func (a *App) chooseCover(ctx context.Context, info artwork.AlbumInfo, tracks []audio.Track, stagingDir string, excludeHashes []string, auto bool) (coverChoice, error) {
+ if auto {
+ local, _, _ := artwork.Discover(ctx, info, tracks, artwork.Options{
+ StagingDir: stagingDir,
+ LocalCoverFolder: a.Cfg.CoverFolder,
+ Offline: true,
+ CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"),
+ ExcludeHashes: excludeHashes,
+ })
+ for _, c := range local {
+ if c.Preferred || c.Source == artwork.SourceFolder || c.Source == artwork.SourceLocalFolder {
+ a.logf("cover: %s (%s)", c.Label, c.Describe())
+ return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
+ }
+ }
+ remote, _, _ := artwork.Discover(ctx, info, tracks, artwork.Options{
+ StagingDir: stagingDir,
+ LocalCoverFolder: a.Cfg.CoverFolder,
+ Offline: a.Cfg.Offline,
+ CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"),
+ ExcludeHashes: excludeHashes,
+ Progress: func(msg string) { a.logf(" %s", msg) },
+ Sources: []artwork.Source{artwork.SourceCoverArt, artwork.SourceDeezer, artwork.SourceITunes},
+ })
+ for _, c := range remote {
+ if c.ExactMatch() {
+ a.logf("cover: %s (%s)", c.Label, c.Describe())
+ return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
+ }
+ }
+ a.logf("cover: no confident match; will file with a placeholder")
+ return a.placeholder(ctx, info)
+ }
+
+ candidates, warnings, err := artwork.Discover(ctx, info, tracks, artwork.Options{
+ StagingDir: stagingDir,
+ LocalCoverFolder: a.Cfg.CoverFolder,
+ Offline: a.Cfg.Offline,
+ CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"),
+ ExcludeHashes: excludeHashes,
+ Progress: func(msg string) { a.logf(" %s", msg) },
+ })
+ if err != nil {
+ return coverChoice{}, err
+ }
+ for _, w := range warnings {
+ a.logf(" warning: %s", w)
+ }
+
+ if len(candidates) > 1 && candidates[0].Preferred && candidates[0].IsLowResolution() {
+ a.logf(" note: the embedded art is only %s; larger options are listed below", candidates[0].Describe())
+ }
+
+ placeholderPath, err := a.makePlaceholder(ctx, info)
+ if err != nil {
+ a.logf(" warning: could not generate a placeholder: %v", err)
+ }
+
+ a.logf("")
+ a.logf("COVER ART")
+ if len(candidates) == 0 {
+ a.logf(" no candidates found from any source.")
+ }
+ for i, c := range candidates {
+ a.logf(" [%d] %-16s %s", i+1, c.Source, c.Describe())
+ a.logf(" %s", c.Label)
+ a.renderImage(c.Path)
+ }
+ if placeholderPath != "" {
+ a.logf(" [0] placeholder a generated stand-in, recorded so you can replace it later")
+ a.logf(" [n] none file the album with no cover at all")
+ } else {
+ a.logf(" [n] none file the album with no cover at all")
+ }
+ a.logf(" [p] pick a file use an image you already have")
+
+ for attempt := 0; attempt < 5; attempt++ {
+ fmt.Fprintf(a.Out, "choose a cover [1-%d, 0=placeholder, n=none, p=file]: ", len(candidates))
+ _ = os.Stdout.Sync()
+ line, _ := bufio.NewReader(a.In).ReadString('\n')
+ answer := strings.TrimSpace(line)
+
+ switch {
+ case answer == "":
+ if len(candidates) > 0 {
+ c := candidates[0]
+ return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
+ }
+ case strings.EqualFold(answer, "n"):
+ a.logf("cover: none")
+ return coverChoice{}, nil
+ case strings.EqualFold(answer, "p"):
+ fmt.Fprintf(a.Out, "path to image: ")
+ _ = os.Stdout.Sync()
+ pathLine, _ := bufio.NewReader(a.In).ReadString('\n')
+ path := strings.TrimSpace(pathLine)
+ if path == "" {
+ continue
+ }
+ path = strings.Trim(path, `"'`)
+ if _, err := os.Stat(path); err != nil {
+ a.logf(" cannot read %s: %v", path, err)
+ continue
+ }
+ return coverChoice{Path: path, Source: artwork.SourceLocalFolder, Label: filepath.Base(path)}, nil
+ case answer == "0" && placeholderPath != "":
+ a.logf("cover: placeholder (recorded; use 'intake library re-cover' once you have real art)")
+ return coverChoice{Path: placeholderPath, Placeholder: true, Source: artwork.SourcePlaceholder, Label: "placeholder"}, nil
+ default:
+ if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(candidates) {
+ c := candidates[n-1]
+ return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
+ }
+ }
+ a.logf(" please answer with a number between 1 and %d, or 0, n, p", len(candidates))
+ }
+ return coverChoice{}, fmt.Errorf("no cover chosen after several attempts")
+}
+
+func (a *App) makePlaceholder(ctx context.Context, info artwork.AlbumInfo) (string, error) {
+ dir := filepath.Join(a.Cfg.StateDir, "placeholders")
+ path := filepath.Join(dir, "placeholder.jpg")
+ if _, err := os.Stat(path); err == nil {
+ return path, nil
+ }
+ if err := artwork.Placeholder(ctx, info, path, a.Cfg.LibraryArtPx); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func (a *App) placeholder(ctx context.Context, info artwork.AlbumInfo) (coverChoice, error) {
+ path, err := a.makePlaceholder(ctx, info)
+ if err != nil {
+ return coverChoice{}, nil
+ }
+ return coverChoice{Path: path, Placeholder: true, Source: artwork.SourcePlaceholder, Label: "placeholder"}, nil
+}
+
+func (a *App) renderImage(path string) {
+ if path == "" || !a.ImagePreview {
+ return
+ }
+ width := a.terminalColumns() - 6
+ if width > 44 {
+ width = 44
+ }
+ if width < 16 {
+ return
+ }
+ cmd := exec.Command("chafa", "--format", "symbols", "--size", fmt.Sprintf("%dx24", width), path)
+ cmd.Stdout = a.Out
+ cmd.Stderr = nil
+ _ = cmd.Run()
+}
+
+func (a *App) terminalColumns() int {
+ if c, err := strconv.Atoi(os.Getenv("COLUMNS")); err == nil && c > 0 {
+ return c
+ }
+ cmd := exec.Command("tput", "cols")
+ var out strings.Builder
+ cmd.Stdout = &out
+ if err := cmd.Run(); err == nil {
+ if c, err := strconv.Atoi(strings.TrimSpace(out.String())); err == nil && c > 0 {
+ return c
+ }
+ }
+ return 80
+}
diff --git a/internal/cli/dryrun_test.go b/internal/cli/dryrun_test.go
@@ -0,0 +1,55 @@
+package cli
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "intake/internal/artist"
+ "intake/internal/config"
+)
+
+func TestDryRunDoesNotWriteAliases(t *testing.T) {
+ root := t.TempDir()
+ var errBuf bytes.Buffer
+ a := &App{
+ Cfg: config.Config{Root: root},
+ DryRun: true,
+ Err: &errBuf,
+ Out: &bytes.Buffer{},
+ }
+ ix := artist.NewIndex()
+ a.rememberAlias(ix, "Some Credit", "some-folder")
+
+ path := artist.AliasPath(root)
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("a dry run wrote %s", path)
+ }
+ if got := ix.Alias["Some Credit"]; got != "some-folder" {
+ t.Errorf("in-memory alias = %q, want %q", got, "some-folder")
+ }
+}
+
+func TestAliasesAreWrittenNormally(t *testing.T) {
+ root := t.TempDir()
+ a := &App{
+ Cfg: config.Config{Root: root},
+ Err: &bytes.Buffer{},
+ Out: &bytes.Buffer{},
+ }
+ ix := artist.NewIndex()
+ a.rememberAlias(ix, "Some Credit", "some-folder")
+
+ if _, err := os.Stat(artist.AliasPath(root)); err != nil {
+ t.Fatalf("aliases were not saved: %v", err)
+ }
+ reloaded := artist.NewIndex()
+ if err := reloaded.LoadAliases(artist.AliasPath(root)); err != nil {
+ t.Fatal(err)
+ }
+ if got := reloaded.Alias["Some Credit"]; got != "some-folder" {
+ t.Errorf("reloaded alias = %q, want %q", got, "some-folder")
+ }
+ _ = filepath.Join
+}
diff --git a/internal/cli/file.go b/internal/cli/file.go
@@ -0,0 +1,223 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "intake/internal/artwork"
+ "intake/internal/execute"
+ "intake/internal/library"
+ "intake/internal/plan"
+ "intake/internal/scan"
+ "intake/internal/state"
+)
+
+type FileResult struct {
+ ArchiveDir string
+ LibraryDir string
+ Files int
+ Bytes int64
+ Cover string
+ Placeholder bool
+ Library bool
+ Removed bool
+ Warnings []string
+}
+
+func (a *App) FilePlan(ctx context.Context, p plan.Plan, cover coverChoice, collision string) (FileResult, error) {
+ var res FileResult
+ res.ArchiveDir = p.ArchiveDir
+ res.LibraryDir = p.LibraryDir
+
+ out, err := execute.Run(ctx, p, execute.Options{
+ KeepStaging: a.Cfg.KeepStaging,
+ VerifyDecode: a.Cfg.VerifyDecode,
+ Collision: collision,
+ CoverPath: cover.Path,
+ Logf: a.logf,
+ })
+ if err != nil {
+ a.forget(p)
+ return res, err
+ }
+ res.Files = len(out.Files)
+ res.Bytes = out.Bytes
+ res.Removed = out.RemovedStaging
+
+ libraryBuilt := false
+ if !a.Cfg.NoLibrary {
+ if err := a.buildLibrary(ctx, p, cover); err != nil {
+ a.record(p, state.PhaseArchived, nil, cover.Placeholder, false)
+ return res, fmt.Errorf("archive is complete, but building the library copy failed: %w", err)
+ }
+ libraryBuilt = true
+ }
+ a.record(p, state.PhaseArchived, nil, cover.Placeholder, libraryBuilt)
+
+ res.Library = libraryBuilt
+ res.Placeholder = cover.Placeholder
+ res.Cover = cover.Label
+ return res, nil
+}
+
+func (a *App) buildLibrary(ctx context.Context, p plan.Plan, cover coverChoice) error {
+ masters := masterFiles(p)
+ names := make([]string, 0, len(p.Tracks))
+ for _, t := range p.Tracks {
+ names = append(names, t.Target)
+ }
+ if len(masters) == 0 {
+ return nil
+ }
+ if a.DryRun {
+ a.logf("dry run: would transcode %d master(s) into the library", len(masters))
+ return nil
+ }
+ coverPath := cover.Path
+ res, err := library.Transcode(ctx, masters, names, p.LibraryDir, library.Options{
+ Codec: a.Cfg.Codec,
+ Quality: a.Cfg.Quality,
+ ArtMaxPx: a.Cfg.LibraryArtPx,
+ CoverPath: coverPath,
+ VerifyDecode: true,
+ Logf: a.logf,
+ })
+ for _, w := range res.Warnings {
+ a.logf(" warning: %s", w)
+ }
+ if err != nil {
+ return err
+ }
+ a.logf("library: -> %s", res.Dir)
+ return nil
+}
+
+func (a *App) PrepareAlbum(ctx context.Context, dir string) (plan.Plan, error) {
+ al, err := scan.One(ctx, dir)
+ if err != nil {
+ return plan.Plan{}, err
+ }
+ if al.Status != scan.StatusReady {
+ return plan.Plan{}, fmt.Errorf("%s: %s (%s)", al.Name, al.Status, strings.Join(al.Notes, "; "))
+ }
+
+ names0 := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{})
+ credit := names0.CreditFromTags
+ if strings.TrimSpace(credit) == "" {
+ credit = names0.AlbumArt
+ }
+ key, chosenCredit, err := a.resolveArtist(al, credit, names0.VariousArt, "")
+ if err != nil {
+ return plan.Plan{}, err
+ }
+ if strings.TrimSpace(chosenCredit) != "" {
+ key.Credit = chosenCredit
+ }
+ return plan.BuildWithArtist(al, a.Cfg.Archive(), a.Cfg.Library(), key, nil, nil), nil
+}
+
+func (a *App) FileAlbum(ctx context.Context, dir string, logf func(string, ...any)) (FileResult, error) {
+ var res FileResult
+ if logf != nil {
+ a.Logf = func(line string) { logf("%s", line) }
+ }
+ al, err := scan.One(ctx, dir)
+ if err != nil {
+ return res, err
+ }
+ if al.Status != scan.StatusReady {
+ return res, fmt.Errorf("%s: %s (%s)", al.Name, al.Status, strings.Join(al.Notes, "; "))
+ }
+ p, err := a.PrepareAlbum(ctx, dir)
+ if err != nil {
+ return res, err
+ }
+ if p.Blocked() {
+ return res, fmt.Errorf("this album needs attention first: %s", firstHardIssue(p))
+ }
+
+ cover := coverChoice{}
+ if !a.Cfg.NoCover {
+ auto := a.Yes
+ a.Yes = true
+ cover, err = a.chooseCover(ctx, artwork.AlbumInfo{
+ Artist: p.AlbumArt,
+ Album: p.AlbumName,
+ Year: p.Year,
+ Tracks: len(p.Tracks),
+ }, al.Tracks, al.Dir, nil, auto)
+ a.Yes = auto
+ if err != nil {
+ return res, err
+ }
+ }
+
+ collision := ""
+ if _, statErr := os.Stat(p.ArchiveDir); statErr == nil {
+ collision = "both"
+ a.logf("destination already exists; keeping both copies")
+ }
+ return a.FilePlan(ctx, p, cover, collision)
+}
+
+func firstHardIssue(p plan.Plan) string {
+ for _, i := range p.Issues {
+ if i.Hard {
+ return i.Text
+ }
+ }
+ return "unknown issue"
+}
+
+func (a *App) CoverCandidates(ctx context.Context, p plan.Plan) ([]artwork.Candidate, []string, error) {
+ al, err := scan.One(ctx, p.Album.Dir)
+ if err != nil {
+ return nil, nil, err
+ }
+ return artwork.Discover(ctx, artwork.AlbumInfo{
+ Artist: p.AlbumArt,
+ Album: p.AlbumName,
+ Year: p.Year,
+ Tracks: len(p.Tracks),
+ }, al.Tracks, artwork.Options{
+ StagingDir: al.Dir,
+ LocalCoverFolder: a.Cfg.CoverFolder,
+ Offline: a.Cfg.Offline,
+ CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"),
+ })
+}
+
+func (a *App) FileAlbumWithCover(ctx context.Context, dir, coverPath string, placeholder bool, logf func(string, ...any)) (FileResult, error) {
+ var res FileResult
+ if logf != nil {
+ a.Logf = func(line string) { logf("%s", line) }
+ }
+ p, err := a.PrepareAlbum(ctx, dir)
+ if err != nil {
+ return res, err
+ }
+ if p.Blocked() {
+ return res, fmt.Errorf("this album needs attention first: %s", firstHardIssue(p))
+ }
+ cover := coverChoice{}
+ if coverPath != "" {
+ cover = coverChoice{Path: coverPath, Placeholder: placeholder}
+ if placeholder {
+ cover.Source = artwork.SourcePlaceholder
+ cover.Label = "placeholder"
+ } else {
+ cover.Source = artwork.SourceLocalFolder
+ cover.Label = filepath.Base(coverPath)
+ }
+ }
+
+ collision := ""
+ if _, statErr := os.Stat(p.ArchiveDir); statErr == nil {
+ collision = "both"
+ a.logf("destination already exists; keeping both copies")
+ }
+ return a.FilePlan(ctx, p, cover, collision)
+}
diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go
@@ -0,0 +1,428 @@
+package cli
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "intake/internal/artist"
+ "intake/internal/names"
+ "intake/internal/normalize"
+ "intake/internal/plan"
+ "intake/internal/scan"
+)
+
+func (a *App) artistIndex(credit string) (*artist.Index, error) {
+ ix := artist.NewIndex()
+
+ root := a.Cfg.Archive()
+ if entries, err := os.ReadDir(root); err == nil {
+ for _, e := range entries {
+ if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
+ ix.AddDir(e.Name())
+ }
+ }
+ }
+ if j, err := a.journal(); err == nil {
+ for _, rec := range j.Albums {
+ if rec.ArchiveDir == "" {
+ continue
+ }
+ if rel, err := filepath.Rel(root, rec.ArchiveDir); err == nil {
+ if parts := strings.Split(rel, string(filepath.Separator)); len(parts) > 0 {
+ ix.AddDir(parts[0])
+ }
+ }
+ }
+ }
+ if err := ix.LoadAliases(artist.AliasPath(a.Cfg.Root)); err != nil {
+ fmt.Fprintf(a.Err, "warning: could not read artist aliases: %v\n", err)
+ }
+ return ix, nil
+}
+
+func (a *App) resolveArtist(al scan.Album, credit string, various bool, editorArtist string) (plan.DirKey, string, error) {
+ if various {
+ return plan.DirKey{Dir: artist.VariousArtists, Credit: credit, Source: "various"}, credit, nil
+ }
+ if strings.TrimSpace(editorArtist) != "" {
+ a.logf("artist folder: %s (from the editor)", editorArtist)
+ return plan.DirKey{Dir: editorArtist, Credit: credit, Source: "editor"}, credit, nil
+ }
+
+ if strings.TrimSpace(credit) == "" {
+ return plan.DirKey{Dir: "unknown-artist", Credit: "", Source: "placeholder"}, "", nil
+ }
+ ix, err := a.artistIndex(credit)
+ if err != nil {
+ return plan.DirKey{}, credit, err
+ }
+
+ artists, isCollab := artist.Split(credit)
+ primary := credit
+ if len(artists) > 0 {
+ primary = artists[0]
+ }
+
+ if isCollab {
+ choice, err := a.askCollab(credit, artists)
+ if err != nil {
+ return plan.DirKey{}, credit, err
+ }
+ if choice != "" && choice != primary {
+ key := normalize.DirName(choice)
+ if key == "" {
+ return plan.DirKey{}, credit, fmt.Errorf("collaboration choice %q has no usable directory name", choice)
+ }
+ a.rememberAlias(ix, credit, key)
+ return plan.DirKey{Dir: key, Credit: choice, Source: "collab"}, choice, nil
+ }
+ }
+
+ if dir, ok := ix.Alias[strings.TrimSpace(credit)]; ok && dir != "" {
+ a.logf("artist folder: %s (from artists.toml)", dir)
+ return plan.DirKey{Dir: dir, Credit: credit, Source: "alias"}, credit, nil
+ }
+
+ m := ix.Resolve(primary)
+ if len(m.Dirs) == 0 {
+ return plan.DirKey{}, credit, fmt.Errorf("could not derive a folder name for artist %q; add an entry to artists.toml", primary)
+ }
+ best := m.Dirs[0]
+
+ if best.ExactAlias || best.ExactDir || best.RomanizedMatch {
+ a.logf("artist folder: %s (%s)", best.Dir, describeMatch(best))
+ return plan.DirKey{Dir: best.Dir, Credit: primary, Source: describeMatch(best)}, primary, nil
+ }
+
+ if best.Score == 0 && best.Dir == normalize.DirName(primary) {
+ a.logf("artist folder: %s (new)", best.Dir)
+ a.rememberAlias(ix, credit, best.Dir)
+ return plan.DirKey{Dir: best.Dir, Credit: primary, Source: "new"}, primary, nil
+ }
+
+ if m.NeedsUser {
+ if a.Yes {
+ a.logf("artist folder: unknown-artist (no readable name for %q; fix it in the editor)", primary)
+ return plan.DirKey{Dir: "unknown-artist", Credit: primary, Source: "placeholder"}, "", nil
+ }
+ fmt.Fprintf(a.Out, "artist %q cannot be romanised automatically.\n", primary)
+ fmt.Fprintf(a.Out, "folder name under archive/ [%s] ", normalize.DirName(primary))
+ _ = os.Stdout.Sync()
+ line, _ := bufio.NewReader(a.In).ReadString('\n')
+ key := normalize.DirName(line)
+ if key == "" {
+ key = "unknown-artist"
+ }
+ a.logf("artist folder: %s (entered by you)", key)
+ if key != credit {
+ a.rememberAlias(ix, credit, key)
+ }
+ a.logf("note: folder %q, while ALBUMARTIST stays %q", key, credit)
+ return plan.DirKey{Dir: key, Credit: credit, Source: "entered"}, "", nil
+ }
+
+ if a.Yes {
+ a.logf("artist folder: %s (%s, auto-selected)", best.Dir, describeMatch(best))
+ a.rememberAlias(ix, credit, best.Dir)
+ return plan.DirKey{Dir: best.Dir, Credit: primary, Source: describeMatch(best)}, primary, nil
+ }
+
+ key, err := a.askArtist(m)
+ if err != nil {
+ return plan.DirKey{}, credit, err
+ }
+ a.rememberAlias(ix, credit, key)
+ return plan.DirKey{Dir: key, Credit: primary, Source: "chosen"}, primary, nil
+}
+
+func describeMatch(c artist.Candidate) string {
+ switch {
+ case c.ExactAlias:
+ return "from artists.toml"
+ case c.ExactDir:
+ return "existing folder"
+ case c.RomanizedMatch:
+ return "romanised match"
+ case c.Fuzzy:
+ return fmt.Sprintf("fuzzy match %.0f%%", c.Score*100)
+ default:
+ return "new"
+ }
+}
+
+func (a *App) askCollab(credit string, parts []string) (string, error) {
+ a.logf("")
+ a.logf("This release has more than one credited artist: %s", credit)
+ for i, p := range parts {
+ a.logf(" [%d] %s", i+1, p)
+ }
+ a.logf(" [c] keep the whole collaboration as its own folder")
+ fmt.Fprintf(a.Out, "file it under which artist? [Enter = %s] ", parts[0])
+ _ = os.Stdout.Sync()
+
+ line, err := bufio.NewReader(a.In).ReadString('\n')
+ if err != nil && line == "" {
+ return parts[0], nil
+ }
+ line = strings.TrimSpace(line)
+ switch {
+ case line == "":
+ return parts[0], nil
+ case strings.EqualFold(line, "c"):
+ return credit, nil
+ default:
+ if n, err := strconv.Atoi(line); err == nil && n >= 1 && n <= len(parts) {
+ return parts[n-1], nil
+ }
+ if strings.TrimSpace(line) != "" {
+ return line, nil
+ }
+ }
+ return parts[0], nil
+}
+
+func (a *App) askArtist(m artist.Match) (string, error) {
+ a.logf("")
+ a.logf("artist %q could belong to an existing folder:", m.Credit)
+ type row struct {
+ dir string
+ count int
+ }
+ var rows []row
+ seen := map[string]bool{}
+ for _, c := range m.Dirs {
+ if seen[c.Dir] {
+ continue
+ }
+ seen[c.Dir] = true
+ rows = append(rows, row{c.Dir, a.albumCount(c.Dir)})
+ if len(rows) >= 8 {
+ break
+ }
+ }
+ for i, r := range rows {
+ rel := ""
+ if r.count > 0 {
+ rel = fmt.Sprintf(" (%d album(s))", r.count)
+ }
+ reason := ""
+ if i < len(m.Dirs) {
+ reason = " " + describeMatch(m.Dirs[i])
+ }
+ a.logf(" [%d] %s%s%s", i+1, r.dir, rel, reason)
+ }
+ a.logf(" [n] create a new folder")
+ a.logf(" [t] type a different name")
+ fmt.Fprintf(a.Out, "choose [Enter = %s] ", rows[0].dir)
+ _ = os.Stdout.Sync()
+
+ line, err := bufio.NewReader(a.In).ReadString('\n')
+ if err != nil && line == "" {
+ return rows[0].dir, nil
+ }
+ line = strings.TrimSpace(line)
+ switch {
+ case line == "":
+ return rows[0].dir, nil
+ case strings.EqualFold(line, "n"):
+ fmt.Fprintf(a.Out, "new folder name [%s] ", normalize.DirName(m.Credit))
+ name, _ := bufio.NewReader(a.In).ReadString('\n')
+ name = normalize.DirName(name)
+ if name == "" {
+ return normalize.DirName(m.Credit), nil
+ }
+ return name, nil
+ case strings.EqualFold(line, "t"):
+ fmt.Fprintf(a.Out, "folder name: ")
+ name, _ := bufio.NewReader(a.In).ReadString('\n')
+ name = normalize.DirName(name)
+ if name == "" {
+ return rows[0].dir, nil
+ }
+ return name, nil
+ default:
+ if n, err := strconv.Atoi(line); err == nil && n >= 1 && n <= len(rows) {
+ return rows[n-1].dir, nil
+ }
+ if key := normalize.DirName(line); key != "" {
+ return key, nil
+ }
+ }
+ return rows[0].dir, nil
+}
+
+func (a *App) albumCount(dir string) int {
+ j, err := a.journal()
+ if err != nil {
+ return 0
+ }
+ n := 0
+ for _, rec := range j.Albums {
+ if rec.ArchiveDir == "" {
+ continue
+ }
+ if rel, err := filepath.Rel(a.Cfg.Archive(), rec.ArchiveDir); err == nil {
+ if parts := strings.Split(rel, string(filepath.Separator)); len(parts) > 1 && parts[0] == dir {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+func (a *App) rememberAlias(ix *artist.Index, credit, dir string) {
+ ix.SetAlias(credit, dir)
+ if a.DryRun {
+ a.logf("dry run: would remember %q in %s", credit, artist.AliasPath(a.Cfg.Root))
+ return
+ }
+ if err := ix.SaveAliases(artist.AliasPath(a.Cfg.Root)); err != nil {
+ fmt.Fprintf(a.Err, "warning: could not save artist aliases: %v\n", err)
+ }
+}
+
+func (a *App) editNames(p *plan.Plan) (*names.Session, error) {
+ session := p.EditSession()
+ if len(session.Tracks) == 0 {
+ return nil, nil
+ }
+ dir, err := os.MkdirTemp("", "intake-names-")
+ if err != nil {
+ return nil, err
+ }
+ defer os.RemoveAll(dir)
+
+ path := filepath.Join(dir, "names.txt")
+ if err := os.WriteFile(path, []byte(names.Render(session)), 0o644); err != nil {
+ return nil, err
+ }
+
+ a.logf("")
+ if len(p.Issues) > 0 {
+ a.logf("Some names need you:")
+ for _, it := range p.Issues {
+ a.logf(" ! %s", it.Text)
+ }
+ } else {
+ a.logf("Editing names.")
+ }
+ a.logf("Opening your editor on:")
+ a.logf(" %s", path)
+ _ = os.Stdout.Sync()
+
+ if !a.openEditor(path) {
+ return nil, fmt.Errorf("could not launch an editor (set $EDITOR or the editor setting in config); the names still need fixing")
+ }
+
+ edited, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ res, err := names.Parse(string(edited), session)
+ if err != nil {
+ return nil, fmt.Errorf("the edited names could not be read: %w", err)
+ }
+ if dupes := names.Duplicates(res.Tracks); len(dupes) > 0 {
+ for target, sources := range dupes {
+ return nil, fmt.Errorf("two tracks would both be named %q (from %s)", target, strings.Join(sources, ", "))
+ }
+ }
+ if res.AlbumDir == "" {
+ return nil, fmt.Errorf("album folder name is still empty; it needs a value")
+ }
+ if res.ArtistDir == "" {
+ return nil, fmt.Errorf("artist folder name is still empty; it needs a value")
+ }
+ p.ApplyNames(res)
+
+ final := session
+ final.Album, final.AlbumDir, final.ArtistDir = res.Album, res.AlbumDir, res.ArtistDir
+ final.ArtistUserEdited = res.ArtistUserEdited
+ final.AlbumUserEdited = res.AlbumUserEdited
+ final.Tracks = make([]names.Item, 0, len(res.Tracks))
+ for _, track := range res.Tracks {
+ for _, before := range session.Tracks {
+ if before.Source == track.Source {
+ track.Suggested = before.Suggested
+ track.Original = before.Original
+ break
+ }
+ }
+ final.Tracks = append(final.Tracks, track)
+ }
+ return &final, nil
+}
+
+func (a *App) openEditor(path string) bool {
+ editor := a.Cfg.EditorCommand()
+ if editor == "" {
+ return false
+ }
+ cmd := exec.Command("sh", "-c", editor+" "+shellQuote(path))
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ if _, ok := err.(*exec.Error); ok {
+ return false
+ }
+ }
+ return true
+}
+
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
+func nameIssues(p plan.Plan) bool {
+ for _, i := range p.Issues {
+ if !i.Hard {
+ continue
+ }
+ t := i.Text
+ if strings.Contains(t, "normalise to the same name") ||
+ strings.Contains(t, "no usable title") ||
+ strings.Contains(t, "empty directory name") ||
+ strings.Contains(t, "no ASCII folder name") {
+ return true
+ }
+ }
+ return false
+}
+
+func sortedDirs(in []string) []string {
+ out := append([]string(nil), in...)
+ sort.Strings(out)
+ return out
+}
+
+func artistDirOfPlan(p plan.Plan) string {
+ if p.ArchiveDir == "" {
+ return ""
+ }
+ return filepath.Base(filepath.Dir(p.ArchiveDir))
+}
+
+func alArtistCredit(credit string) string { return strings.TrimSpace(credit) }
+
+func editorFixesAll(p plan.Plan) bool {
+ for _, i := range p.Issues {
+ if !i.Hard {
+ continue
+ }
+ t := i.Text
+ fixable := strings.Contains(t, "no usable title") ||
+ strings.Contains(t, "empty directory name") ||
+ strings.Contains(t, "no ASCII folder name")
+ if !fixable {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/cli/progress_test.go b/internal/cli/progress_test.go
@@ -0,0 +1,46 @@
+package cli
+
+import (
+ "context"
+ "io"
+ "strings"
+ "testing"
+
+ "intake/internal/config"
+ "intake/internal/scan"
+)
+
+func TestFileAlbumKeepsAnInstalledLogger(t *testing.T) {
+ var lines []string
+ app := New(config.Defaults(), io.Discard, io.Discard, strings.NewReader(""))
+ app.Logf = func(line string) { lines = append(lines, line) }
+
+ if _, err := app.FileAlbum(context.Background(), "/nonexistent/album", nil); err == nil {
+ t.Skip("expected a failure for a missing album")
+ }
+
+ app.logf("still listening")
+ found := false
+ for _, l := range lines {
+ if strings.Contains(l, "still listening") {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("the installed logger was cleared by FileAlbum; got %v", lines)
+ }
+}
+
+func TestQuietSuppressesEverything(t *testing.T) {
+ var out strings.Builder
+ app := New(config.Defaults(), &out, io.Discard, strings.NewReader(""))
+ app.Quiet = true
+ app.Logf = func(string) { t.Error("a quiet app must not call its logger") }
+ app.logf("this must not appear")
+ app.logf("nor this")
+ if out.Len() != 0 {
+ t.Errorf("quiet app wrote to its output: %q", out.String())
+ }
+}
+
+var _ = scan.Album{}
diff --git a/internal/config/config.go b/internal/config/config.go
@@ -0,0 +1,227 @@
+package config
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+type Config struct {
+ Root string
+ StagingDir string
+ ArchiveDir string
+ LibraryDir string
+ StateDir string
+ Editor string
+ Codec string
+ Quality int
+ DiscPrefix string
+ LibraryArtPx int
+ ArchiveArtPx int
+ Contacts string
+ UserAgent string
+ CoverFolder string
+ Placeholder string
+ KeepStaging bool
+ Offline bool
+ NoCover bool
+ NoLibrary bool
+ VerifyDecode string
+ DryRun bool
+ Yes bool
+
+ Path string
+}
+
+func Defaults() Config {
+ home, _ := os.UserHomeDir()
+ state := os.Getenv("XDG_STATE_HOME")
+ if state == "" {
+ state = filepath.Join(home, ".local", "state")
+ }
+ return Config{
+ Root: filepath.Join(home, "music"),
+ StagingDir: "staging",
+ ArchiveDir: "archive",
+ LibraryDir: "library",
+ StateDir: filepath.Join(state, "intake"),
+ Codec: "vorbis",
+ Quality: 9,
+ DiscPrefix: "always",
+ LibraryArtPx: 1000,
+ ArchiveArtPx: 1600,
+ UserAgent: "intake/0.1 ( https://example.invalid )",
+ Placeholder: "PLACEHOLDER.jpg",
+ VerifyDecode: "sample",
+ }
+}
+
+func DefaultConfigPath() string {
+ if p := os.Getenv("INTAKE_CONFIG"); p != "" {
+ return p
+ }
+ base := os.Getenv("XDG_CONFIG_HOME")
+ if base == "" {
+ home, _ := os.UserHomeDir()
+ base = filepath.Join(home, ".config")
+ }
+ return filepath.Join(base, "intake", "config.toml")
+}
+
+func Load(path string) (Config, error) {
+ cfg := Defaults()
+ if root := os.Getenv("INTAKE_ROOT"); root != "" {
+ cfg.Root = root
+ }
+ if path == "" {
+ path = DefaultConfigPath()
+ }
+ if err := applyFile(&cfg, path); err != nil {
+ return cfg, err
+ }
+ cfg.Path = path
+ return cfg, nil
+}
+
+func applyFile(cfg *Config, path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ defer f.Close()
+
+ sc := bufio.NewScanner(f)
+ line := 0
+ for sc.Scan() {
+ line++
+ text := strings.TrimSpace(sc.Text())
+ if text == "" || strings.HasPrefix(text, "#") {
+ continue
+ }
+ key, value, ok := strings.Cut(text, "=")
+ if !ok {
+ return fmt.Errorf("%s:%d: expected key = value", path, line)
+ }
+ key = strings.TrimSpace(key)
+ value = strings.TrimSpace(value)
+ if i := strings.IndexByte(value, '#'); i >= 0 && !strings.HasPrefix(value, "\"") {
+ value = strings.TrimSpace(value[:i])
+ }
+ value = strings.Trim(value, `"'`)
+ if err := cfg.set(key, value); err != nil {
+ return fmt.Errorf("%s:%d: %w", path, line, err)
+ }
+ }
+ return sc.Err()
+}
+
+func (c *Config) set(key, value string) error {
+ switch key {
+ case "root":
+ c.Root = value
+ case "staging_dir":
+ c.StagingDir = value
+ case "archive_dir":
+ c.ArchiveDir = value
+ case "library_dir":
+ c.LibraryDir = value
+ case "state_dir":
+ c.StateDir = value
+ case "editor":
+ c.Editor = value
+ case "codec":
+ c.Codec = value
+ case "quality":
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("quality: %q is not a number", value)
+ }
+ c.Quality = n
+ case "disc_prefix":
+ c.DiscPrefix = value
+ case "library_art_px":
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("library_art_px: %q is not a number", value)
+ }
+ c.LibraryArtPx = n
+ case "archive_art_px":
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("archive_art_px: %q is not a number", value)
+ }
+ c.ArchiveArtPx = n
+ case "contacts":
+ c.Contacts = value
+ case "user_agent":
+ c.UserAgent = value
+ case "cover_folder":
+ c.CoverFolder = value
+ case "placeholder":
+ c.Placeholder = value
+ case "keep_staging":
+ b, err := strconv.ParseBool(value)
+ if err != nil {
+ return fmt.Errorf("keep_staging: %q is not a boolean", value)
+ }
+ c.KeepStaging = b
+ case "verify_decode":
+ c.VerifyDecode = value
+ case "offline":
+ b, err := strconv.ParseBool(value)
+ if err != nil {
+ return fmt.Errorf("offline: %q is not a boolean", value)
+ }
+ c.Offline = b
+ case "no_cover":
+ b, err := strconv.ParseBool(value)
+ if err != nil {
+ return fmt.Errorf("no_cover: %q is not a boolean", value)
+ }
+ c.NoCover = b
+ case "no_library":
+ b, err := strconv.ParseBool(value)
+ if err != nil {
+ return fmt.Errorf("no_library: %q is not a boolean", value)
+ }
+ c.NoLibrary = b
+ default:
+ return fmt.Errorf("unknown setting %q", key)
+ }
+ return nil
+}
+
+func expand(p string) string {
+ if p == "~" || strings.HasPrefix(p, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
+ }
+ }
+ return p
+}
+
+func (c Config) Staging() string { return filepath.Join(expand(c.Root), c.StagingDir) }
+
+func (c Config) Archive() string { return filepath.Join(expand(c.Root), c.ArchiveDir) }
+
+func (c Config) Library() string { return filepath.Join(expand(c.Root), c.LibraryDir) }
+
+func (c Config) EditorCommand() string {
+ if c.Editor != "" {
+ return c.Editor
+ }
+ if v := os.Getenv("VISUAL"); v != "" {
+ return v
+ }
+ if v := os.Getenv("EDITOR"); v != "" {
+ return v
+ }
+ return "vi"
+}
diff --git a/internal/execute/execute.go b/internal/execute/execute.go
@@ -0,0 +1,307 @@
+package execute
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "intake/internal/artwork"
+ "intake/internal/audio"
+ "intake/internal/plan"
+ "intake/internal/tags"
+)
+
+type Options struct {
+ DryRun bool
+ KeepStaging bool
+ VerifyDecode string
+ Collision string
+ CoverPath string
+ CoverName string
+ Logf func(format string, args ...any)
+}
+
+type Result struct {
+ ArchiveDir string
+ Files []string
+ RemovedStaging bool
+ Bytes int64
+ StagingLeftovers []string
+}
+
+func Run(ctx context.Context, p plan.Plan, opts Options) (Result, error) {
+ logf := opts.Logf
+ if logf == nil {
+ logf = func(string, ...any) {}
+ }
+ var res Result
+ if p.ArchiveDir == "" {
+ return res, fmt.Errorf("plan has no archive directory")
+ }
+ res.ArchiveDir = p.ArchiveDir
+
+ if opts.DryRun {
+ logf("dry run: would create %s", p.ArchiveDir)
+ if opts.CoverPath != "" {
+ logf("dry run: would write %s and embed it as the front cover", coverName(opts))
+ }
+ for _, t := range p.Tracks {
+ logf("dry run: copy %s -> %s/%s", filepath.Base(t.Source), filepath.Base(p.ArchiveDir), t.Target)
+ }
+ return res, nil
+ }
+
+ parent := filepath.Dir(p.ArchiveDir)
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ return res, err
+ }
+
+ tmp, err := os.MkdirTemp(parent, "."+filepath.Base(p.ArchiveDir)+".intake-")
+ if err != nil {
+ return res, err
+ }
+ defer os.RemoveAll(tmp)
+
+ if opts.CoverPath != "" {
+ if err := copyFile(opts.CoverPath, filepath.Join(tmp, coverName(opts))); err != nil {
+ return res, fmt.Errorf("copying cover: %w", err)
+ }
+ }
+
+ written := make([]string, 0, len(p.Tracks))
+ for _, t := range p.Tracks {
+ dst := filepath.Join(tmp, t.Target)
+ if err := copyFile(t.Source, dst); err != nil {
+ return res, fmt.Errorf("copy %s: %w", filepath.Base(t.Source), err)
+ }
+ lines, err := tags.Write(t.Tags)
+ if err != nil {
+ return res, err
+ }
+ if err := audio.WriteTags(ctx, dst, lines, opts.CoverPath != ""); err != nil {
+ return res, err
+ }
+ if opts.CoverPath != "" {
+ if err := artwork.Embed(ctx, dst, opts.CoverPath); err != nil {
+ return res, err
+ }
+ }
+ written = append(written, dst)
+ // An album is minutes of copying and encoding with nothing to show for
+ // it otherwise, which reads as a hang.
+ logf("copied %s", t.Target)
+ }
+
+ if err := verify(ctx, p, written, opts.VerifyDecode, opts, logf); err != nil {
+ return res, err
+ }
+
+ if err := promote(p.ArchiveDir, tmp, opts.Collision, logf); err != nil {
+ return res, err
+ }
+ finalPaths := make([]string, 0, len(written))
+ for _, f := range written {
+ rel, err := filepath.Rel(tmp, f)
+ if err != nil {
+ rel = filepath.Base(f)
+ }
+ finalPaths = append(finalPaths, filepath.Join(p.ArchiveDir, rel))
+ }
+ res.Files = finalPaths
+
+ if !opts.KeepStaging {
+ for _, t := range p.Tracks {
+ if err := os.Remove(t.Source); err != nil && !os.IsNotExist(err) {
+ return res, fmt.Errorf("intake succeeded but could not remove staged file %s: %w", filepath.Base(t.Source), err)
+ }
+ }
+ albumDir := filepath.Dir(p.Tracks[0].Source)
+ leftovers, err := cleanStaging(albumDir)
+ if err != nil {
+ logf("note: could not fully clear %s: %v", albumDir, err)
+ }
+ if len(leftovers) > 0 {
+ logf("removed non-audio leftovers from staging: %s", strings.Join(leftovers, ", "))
+ }
+ res.StagingLeftovers = leftovers
+ res.RemovedStaging = true
+ }
+ for _, t := range res.Files {
+ if fi, err := os.Stat(t); err == nil {
+ res.Bytes += fi.Size()
+ }
+ }
+ return res, nil
+}
+
+func coverName(opts Options) string {
+ if opts.CoverName != "" {
+ return opts.CoverName
+ }
+ return "cover.jpg"
+}
+
+func verify(ctx context.Context, p plan.Plan, written []string, mode string, opts Options, logf func(string, ...any)) error {
+ if len(written) != len(p.Tracks) {
+ return fmt.Errorf("verification failed: wrote %d files for %d tracks", len(written), len(p.Tracks))
+ }
+ for i, f := range written {
+ src := p.Tracks[i]
+ info, err := audio.Probe(ctx, f)
+ if err != nil {
+ return fmt.Errorf("verification failed reading %s: %w", filepath.Base(f), err)
+ }
+ if info.Codec == "" {
+ return fmt.Errorf("verification failed: %s has no decodable audio stream", filepath.Base(f))
+ }
+ if src.Ext == ".flac" && info.Codec != "flac" {
+ return fmt.Errorf("verification failed: %s codec is %q, expected flac", filepath.Base(f), info.Codec)
+ }
+ if src.Duration > 0 && info.Duration > 0 {
+ diff := info.Duration - src.Duration
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 0.25 {
+ return fmt.Errorf("verification failed: %s duration changed by %.3fs", filepath.Base(f), diff)
+ }
+ }
+ if got, _ := tags.Get(info.Tags, tags.FieldTitle); strings.TrimSpace(got) == "" {
+ return fmt.Errorf("verification failed: %s has no TITLE after tagging", filepath.Base(f))
+ }
+ if got, _ := tags.Get(info.Tags, tags.FieldAlbumArtist); strings.TrimSpace(got) == "" {
+ return fmt.Errorf("verification failed: %s has no ALBUMARTIST after tagging", filepath.Base(f))
+ }
+ stray := tags.Dropped(info.Tags)
+ if len(stray) > 0 {
+ return fmt.Errorf("verification failed: %s still carries non-whitelisted tags %v", filepath.Base(f), stray)
+ }
+ if opts.CoverPath != "" {
+ if info.ArtCount == 0 {
+ return fmt.Errorf("verification failed: %s has no embedded cover", filepath.Base(f))
+ }
+ }
+ }
+
+ switch mode {
+ case "all":
+ for _, f := range written {
+ if err := audio.DecodeOK(ctx, f); err != nil {
+ return fmt.Errorf("verification failed: %w", err)
+ }
+ }
+ case "sample":
+ idx := []int{0, len(written) / 2, len(written) - 1}
+ for _, i := range idx {
+ if err := audio.DecodeOK(ctx, written[i]); err != nil {
+ return fmt.Errorf("verification failed: %w", err)
+ }
+ }
+ }
+ logf("verified %d file(s) (decode: %s)", len(written), mode)
+ return nil
+}
+
+func promote(target, tmp, collision string, logf func(string, ...any)) error {
+ if _, err := os.Stat(target); err == nil {
+ switch collision {
+ case "replace":
+ if err := os.RemoveAll(target); err != nil {
+ return err
+ }
+ case "both":
+ alt := target + " (2)"
+ for i := 3; ; i++ {
+ if _, err := os.Stat(alt); os.IsNotExist(err) {
+ break
+ }
+ alt = fmt.Sprintf("%s (%d)", target, i)
+ }
+ logf("destination exists; keeping both as %s", filepath.Base(alt))
+ target = alt
+ case "merge":
+ if err := mergeInto(tmp, target); err != nil {
+ return err
+ }
+ logf("merged into existing %s", filepath.Base(target))
+ return nil
+ default:
+ return fmt.Errorf("destination already exists: %s", target)
+ }
+ }
+ if err := os.Rename(tmp, target); err != nil {
+ return fmt.Errorf("promote %s: %w", target, err)
+ }
+ logf("placed %s", target)
+ return nil
+}
+
+func mergeInto(src, dst string) error {
+ entries, err := os.ReadDir(src)
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ from := filepath.Join(src, e.Name())
+ to := filepath.Join(dst, e.Name())
+ if _, err := os.Stat(to); err == nil {
+ return fmt.Errorf("merge conflict: %s already exists", to)
+ }
+ if err := os.Rename(from, to); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func copyFile(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 cleanStaging(dir string) ([]string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var removed []string
+ for _, e := range entries {
+ if e.IsDir() || audio.IsAudio(e.Name()) {
+ continue
+ }
+ path := filepath.Join(dir, e.Name())
+ if err := os.Remove(path); err != nil {
+ return removed, err
+ }
+ removed = append(removed, e.Name())
+ }
+ for i := 0; i < 4 && dir != "" && dir != "/" && dir != "."; i++ {
+ left, err := os.ReadDir(dir)
+ if err != nil || len(left) > 0 {
+ break
+ }
+ if err := os.Remove(dir); err != nil {
+ break
+ }
+ dir = filepath.Dir(dir)
+ }
+ return removed, nil
+}
diff --git a/internal/library/library.go b/internal/library/library.go
@@ -0,0 +1,333 @@
+package library
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "intake/internal/artwork"
+ "intake/internal/audio"
+ "intake/internal/tags"
+)
+
+type Options struct {
+ Codec string
+ Quality int
+ ArtMaxPx int
+ ArtQuality int
+ CoverPath string
+ VerifyDecode bool
+ Logf func(format string, args ...any)
+}
+
+type Result struct {
+ Dir string
+ Files []string
+ Bytes int64
+ Cover string
+ Skipped bool
+ Warnings []string
+}
+
+const DefaultQuality = 9
+
+func Transcode(ctx context.Context, masterPaths []string, targetNames []string, outDir string, opts Options) (Result, error) {
+ logf := opts.Logf
+ if logf == nil {
+ logf = func(string, ...any) {}
+ }
+ var res Result
+ res.Dir = outDir
+
+ if len(masterPaths) != len(targetNames) {
+ return res, fmt.Errorf("internal error: %d masters but %d target names", len(masterPaths), len(targetNames))
+ }
+ if len(masterPaths) == 0 {
+ return res, fmt.Errorf("no masters to transcode")
+ }
+ codec := strings.ToLower(strings.TrimSpace(opts.Codec))
+ if codec == "" {
+ // vorbis by default: ffmpeg 9 drops tags on opus and can't attach cover art
+ // to ogg at all, so opus needs the opustags binary and still won't carry a
+ // picture. the masters are lossless, so this can be redone in anything later.
+ codec = "vorbis"
+ }
+ if codec == "opus" && !hasTool("opustags") {
+ return res, fmt.Errorf("the opus codec needs the opustags tool, which is not installed; use the vorbis codec or install opustags")
+ }
+
+ quality := opts.Quality
+ if quality <= 0 {
+ quality = DefaultQuality
+ }
+ artMax := opts.ArtMaxPx
+ if artMax <= 0 {
+ artMax = 1000
+ }
+ artQuality := opts.ArtQuality
+ if artQuality <= 0 {
+ artQuality = 85
+ }
+
+ parent := filepath.Dir(outDir)
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ return res, err
+ }
+ tmp, err := os.MkdirTemp(parent, "."+filepath.Base(outDir)+".intake-lib-")
+ if err != nil {
+ return res, err
+ }
+ defer os.RemoveAll(tmp)
+
+ embedCover := ""
+ if opts.CoverPath != "" {
+ if _, err := os.Stat(opts.CoverPath); err == nil {
+ libCover := filepath.Join(tmp, "cover.jpg")
+ size, err := artwork.Optimize(ctx, opts.CoverPath, artMax, artQuality, libCover)
+ if err != nil {
+ res.Warnings = append(res.Warnings, fmt.Sprintf("could not optimise the cover: %v", err))
+ } else {
+ embedCover = libCover
+ res.Cover = filepath.Join(outDir, "cover.jpg")
+ logf("cover: optimised to %dx%d max (%s)", artMax, artMax, humanBytes(size))
+ }
+ }
+ }
+
+ outExt := ".ogg"
+ if codec == "opus" {
+ outExt = ".opus"
+ }
+
+ written := make([]string, 0, len(masterPaths))
+ for i, master := range masterPaths {
+ name := strings.TrimSuffix(targetNames[i], filepath.Ext(targetNames[i])) + outExt
+ dst := filepath.Join(tmp, name)
+
+ if err := encode(ctx, master, dst, codec, quality); err != nil {
+ return res, err
+ }
+
+ comments, err := masterComments(ctx, master)
+ if err != nil {
+ return res, fmt.Errorf("reading tags from %s: %w", filepath.Base(master), err)
+ }
+ if embedCover != "" {
+ pic, err := pictureComment(ctx, embedCover)
+ if err != nil {
+ return res, err
+ }
+ comments = append(comments, pic)
+ }
+ if err := writeComments(ctx, dst, codec, comments); err != nil {
+ return res, err
+ }
+ if err := verifyOne(ctx, master, dst, opts.VerifyDecode); err != nil {
+ return res, err
+ }
+ written = append(written, dst)
+ logf("encoded %s", name)
+ }
+
+ if _, err := os.Stat(outDir); err == nil {
+ if err := os.RemoveAll(outDir); err != nil {
+ return res, err
+ }
+ }
+ if err := os.Rename(tmp, outDir); err != nil {
+ return res, fmt.Errorf("promoting library directory: %w", err)
+ }
+
+ res.Files = make([]string, 0, len(written))
+ for _, w := range written {
+ res.Files = append(res.Files, filepath.Join(outDir, filepath.Base(w)))
+ }
+ for _, f := range res.Files {
+ if info, err := os.Stat(f); err == nil {
+ res.Bytes += info.Size()
+ }
+ }
+ if res.Cover != "" {
+ if info, err := os.Stat(res.Cover); err == nil {
+ res.Bytes += info.Size()
+ }
+ }
+ logf("library: %d track(s), %s", len(res.Files), humanBytes(res.Bytes))
+ return res, nil
+}
+
+func encode(ctx context.Context, src, dst, codec string, quality int) error {
+ var args []string
+ switch codec {
+ case "vorbis":
+ args = []string{"-c:a", "libvorbis", "-q:a", strconv.Itoa(quality)}
+ case "opus":
+ args = []string{"-c:a", "libopus", "-b:a", strconv.Itoa(quality) + "k"}
+ default:
+ return fmt.Errorf("unknown codec %q", codec)
+ }
+ full := append([]string{"-hide_banner", "-loglevel", "error", "-y",
+ "-i", src, "-map", "0:a", "-map_metadata", "-1"}, args...)
+ full = append(full, dst)
+
+ var errb bytes.Buffer
+ cmd := exec.CommandContext(ctx, "ffmpeg", full...)
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("encoding %s: %s", filepath.Base(src), strings.TrimSpace(errb.String()))
+ }
+ return nil
+}
+
+func masterComments(ctx context.Context, master string) ([]string, error) {
+ cmd := exec.CommandContext(ctx, "metaflac", "--export-tags-to=-", master)
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("%s", strings.TrimSpace(errb.String()))
+ }
+ return filterComments(strings.Split(out.String(), "\n")), nil
+}
+
+func filterComments(lines []string) []string {
+ var comments []string
+ for _, line := range lines {
+ line = strings.TrimRight(line, "\r")
+ if line == "" {
+ continue
+ }
+ key, _, ok := strings.Cut(line, "=")
+ if !ok || !tags.IsKept(key) {
+ continue
+ }
+ comments = append(comments, line)
+ }
+ return comments
+}
+
+func writeComments(ctx context.Context, path, codec string, comments []string) error {
+ switch codec {
+ case "vorbis":
+ file, err := os.CreateTemp("", "intake-comments-*.txt")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ if _, err := file.WriteString(strings.Join(comments, "\n") + "\n"); err != nil {
+ file.Close()
+ return err
+ }
+ file.Close()
+ cmd := exec.CommandContext(ctx, "vorbiscomment", "-a", "-c", file.Name(), path)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("tagging %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
+ }
+ return nil
+ case "opus":
+ args := []string{"-a"}
+ for _, c := range comments {
+ args = append(args, "-t", c)
+ }
+ args = append(args, path)
+ cmd := exec.CommandContext(ctx, "opustags", args...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("tagging %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
+ }
+ return nil
+ }
+ return fmt.Errorf("unknown codec %q", codec)
+}
+
+func verifyOne(ctx context.Context, master, dst string, decode bool) error {
+ out, err := audio.Probe(ctx, dst)
+ if err != nil {
+ return fmt.Errorf("library verification failed reading %s: %w", filepath.Base(dst), err)
+ }
+ if out.Codec == "" {
+ return fmt.Errorf("library verification failed: %s has no audio stream", filepath.Base(dst))
+ }
+ in, err := audio.Probe(ctx, master)
+ if err != nil {
+ return err
+ }
+ if in.Duration > 0 && out.Duration > 0 {
+ diff := out.Duration - in.Duration
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 0.25 {
+ return fmt.Errorf("library verification failed: %s duration drifted by %.3fs", filepath.Base(dst), diff)
+ }
+ }
+ for _, field := range []string{tags.FieldTitle, tags.FieldAlbum, tags.FieldAlbumArtist, tags.FieldTrackNumber} {
+ want, _ := tags.Get(in.Tags, field)
+ got, ok := tags.Get(out.Tags, field)
+ if !ok || strings.TrimSpace(got) == "" {
+ return fmt.Errorf("library verification failed: %s is missing %s", filepath.Base(dst), field)
+ }
+ if want != got {
+ return fmt.Errorf("library verification failed: %s has %s=%q, master has %q", filepath.Base(dst), field, got, want)
+ }
+ }
+ if decode {
+ if err := audio.DecodeOK(ctx, dst); err != nil {
+ return fmt.Errorf("library verification failed: %w", err)
+ }
+ }
+ return nil
+}
+
+func pictureComment(ctx context.Context, imagePath string) (string, error) {
+ data, err := os.ReadFile(imagePath)
+ if err != nil {
+ return "", err
+ }
+ mime := "image/jpeg"
+ if strings.EqualFold(filepath.Ext(imagePath), ".png") {
+ mime = "image/png"
+ }
+ var buf bytes.Buffer
+ writeU32 := func(n uint32) {
+ buf.WriteByte(byte(n >> 24))
+ buf.WriteByte(byte(n >> 16))
+ buf.WriteByte(byte(n >> 8))
+ buf.WriteByte(byte(n))
+ }
+ writeU32(3)
+ writeU32(uint32(len(mime)))
+ buf.WriteString(mime)
+ writeU32(0)
+ writeU32(0)
+ writeU32(0)
+ writeU32(0)
+ writeU32(0)
+ writeU32(uint32(len(data)))
+ buf.Write(data)
+
+ return "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
+}
+
+func hasTool(name string) bool {
+ _, err := exec.LookPath(name)
+ return err == nil
+}
+
+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])
+}
diff --git a/internal/library/library_test.go b/internal/library/library_test.go
@@ -0,0 +1,74 @@
+package library
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/binary"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestFilterComments(t *testing.T) {
+ got := filterComments([]string{
+ "TRACKNUMBER=1",
+ "GENRE=IDM",
+ "TITLE=Song",
+ "REPLAYGAIN_TRACK_GAIN=-3 dB",
+ "",
+ "no-equals-sign",
+ "ALBUMARTIST=Band",
+ "DATE=2001",
+ })
+ want := []string{"TRACKNUMBER=1", "TITLE=Song", "ALBUMARTIST=Band", "DATE=2001"}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("comment %d = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestPictureCommentStructure(t *testing.T) {
+ dir := t.TempDir()
+ img := filepath.Join(dir, "cover.jpg")
+ payload := []byte{0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3, 4}
+ if err := os.WriteFile(img, payload, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ comment, err := pictureComment(context.Background(), img)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const prefix = "METADATA_BLOCK_PICTURE="
+ if !strings.HasPrefix(comment, prefix) {
+ t.Fatalf("comment should start with %s", prefix)
+ }
+ raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(comment, prefix))
+ if err != nil {
+ t.Fatalf("payload is not valid base64: %v", err)
+ }
+ if got := binary.BigEndian.Uint32(raw[0:4]); got != 3 {
+ t.Errorf("picture type = %d, want 3 (front cover)", got)
+ }
+ mimeLen := int(binary.BigEndian.Uint32(raw[4:8]))
+ mime := string(raw[8 : 8+mimeLen])
+ if mime != "image/jpeg" {
+ t.Errorf("mime = %q, want image/jpeg", mime)
+ }
+ off := 8 + mimeLen
+ descLen := int(binary.BigEndian.Uint32(raw[off : off+4]))
+ off += 4 + descLen + 16
+ dataLen := int(binary.BigEndian.Uint32(raw[off : off+4]))
+ off += 4
+ if dataLen != len(payload) {
+ t.Fatalf("data length = %d, want %d", dataLen, len(payload))
+ }
+ if string(raw[off:off+dataLen]) != string(payload) {
+ t.Error("image bytes were not preserved")
+ }
+}
diff --git a/internal/names/names.go b/internal/names/names.go
@@ -0,0 +1,212 @@
+package names
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+type Item struct {
+ Source string
+ Prefix string
+ Suffix string
+ Original string
+ KeepAsIs bool
+ Number int
+ Disc int
+ Suggested string
+}
+
+func (i Item) Full() string {
+ if i.KeepAsIs {
+ return filepath.Base(i.Source)
+ }
+ return i.Prefix + i.Suffix
+}
+
+type Session struct {
+ Album string
+ AlbumDir string
+ ArtistDir string
+ Tracks []Item
+ ArtistUserEdited bool
+ AlbumUserEdited bool
+}
+
+type Result struct {
+ Album string
+ AlbumDir string
+ ArtistDir string
+ Tracks []Item
+ AlbumSet bool
+ AlbumUserEdited bool
+ ArtistUserEdited bool
+ TrackEdited map[string]bool
+}
+
+func Render(s Session) string {
+ var b strings.Builder
+ b.WriteString("# intake names for this album\n")
+ b.WriteString("#\n")
+ b.WriteString("# 'album' is the ALBUM tag and the folder name; 'artist' is the folder\n")
+ b.WriteString("# under archive/ and library/. Both must be safe directory names:\n")
+ b.WriteString("# lowercase, no slashes. Edit them if normalisation could not work\n")
+ b.WriteString("# them out (a Japanese title, for example, has no ASCII form).\n")
+ b.WriteString("#\n")
+ b.WriteString("# Track lines: the \"disc-track_\" part is generated and must not change.\n")
+ b.WriteString("# Edit what comes after it. Write '=' to keep the original filename.\n")
+ b.WriteString("# Delete a track line to leave that file untouched (it is skipped).\n")
+ b.WriteString("# An empty file aborts the whole album.\n")
+ b.WriteString("\n")
+ fmt.Fprintf(&b, "album: %s\n", s.AlbumDir)
+ fmt.Fprintf(&b, "artist: %s\n", s.ArtistDir)
+ if s.AlbumDir == "" || s.ArtistDir == "" {
+ b.WriteString("#\n")
+ if s.AlbumDir == "" {
+ fmt.Fprintf(&b, "# The album title %q has no ASCII form. Type a folder name above.\n", s.Album)
+ }
+ if s.ArtistDir == "" {
+ b.WriteString("# The artist has no ASCII form. Type a folder name above.\n")
+ }
+ }
+ b.WriteString("\n")
+ for _, it := range s.Tracks {
+ name := it.Full()
+ if it.Original != "" && it.Original != name {
+ fmt.Fprintf(&b, "%-60s # %s\n", name, it.Original)
+ continue
+ }
+ fmt.Fprintf(&b, "%s\n", name)
+ }
+ return b.String()
+}
+
+func Parse(text string, s Session) (Result, error) {
+ res := Result{Album: s.Album, AlbumDir: s.AlbumDir, ArtistDir: s.ArtistDir}
+
+ byPrefix := map[string]int{}
+ for i, it := range s.Tracks {
+ byPrefix[it.Prefix] = i
+ }
+ out := make([]Item, len(s.Tracks))
+ copy(out, s.Tracks)
+ seen := make([]bool, len(s.Tracks))
+
+ lineNo := 0
+ for _, raw := range strings.Split(text, "\n") {
+ lineNo++
+ line := strings.TrimRight(raw, " \t\r")
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ body := trimmed
+ if i := strings.Index(body, " #"); i >= 0 {
+ body = strings.TrimSpace(body[:i])
+ }
+
+ if k, v, ok := strings.Cut(body, ":"); ok {
+ key := strings.ToLower(strings.TrimSpace(k))
+ if key == "album" || key == "artist" {
+ val := cleanDirValue(v)
+ if val == "" {
+ return Result{}, fmt.Errorf("line %d: %s has no name", lineNo, key)
+ }
+ if key == "album" {
+ res.AlbumUserEdited = val != s.AlbumDir
+ res.Album, res.AlbumDir, res.AlbumSet = val, val, true
+ } else {
+ res.ArtistUserEdited = val != s.ArtistDir
+ res.ArtistDir = val
+ }
+ continue
+ }
+ }
+
+ if strings.HasPrefix(body, "=") {
+ idx := -1
+ for j := range out {
+ if !seen[j] {
+ idx = j
+ break
+ }
+ }
+ if idx < 0 {
+ return Result{}, fmt.Errorf("line %d: more entries than tracks", lineNo)
+ }
+ out[idx].KeepAsIs = true
+ seen[idx] = true
+ continue
+ }
+
+ prefix, rest, ok := strings.Cut(body, "_")
+ if !ok {
+ return Result{}, fmt.Errorf("line %d: %q is missing the generated prefix (it should look like 1-01_name.flac)", lineNo, body)
+ }
+ idx, known := byPrefix[prefix+"_"]
+ if !known {
+ return Result{}, fmt.Errorf("line %d: unknown prefix %q (do not change the disc-track number)", lineNo, prefix+"_")
+ }
+ if seen[idx] {
+ return Result{}, fmt.Errorf("line %d: %s appears twice", lineNo, prefix)
+ }
+ rest = strings.TrimSpace(rest)
+ if rest == "" {
+ return Result{}, fmt.Errorf("line %d: %s has no name after the prefix", lineNo, prefix)
+ }
+ if strings.ContainsAny(rest, "/\\") {
+ return Result{}, fmt.Errorf("line %d: %q contains a path separator", lineNo, rest)
+ }
+ if rest == "." || rest == ".." {
+ return Result{}, fmt.Errorf("line %d: %q is not a valid name", lineNo, rest)
+ }
+ if rest != out[idx].Suggested {
+ if res.TrackEdited == nil {
+ res.TrackEdited = map[string]bool{}
+ }
+ res.TrackEdited[out[idx].Source] = true
+ }
+ out[idx].Suffix = rest
+ out[idx].KeepAsIs = false
+ seen[idx] = true
+ }
+
+ for i := range out {
+ if seen[i] {
+ res.Tracks = append(res.Tracks, out[i])
+ }
+ }
+ if len(res.Tracks) == 0 {
+ return Result{}, fmt.Errorf("no track names remained; nothing to do")
+ }
+ return res, nil
+}
+
+func cleanDirValue(v string) string {
+ v = strings.TrimSpace(v)
+ v = strings.Trim(v, "/")
+ v = strings.ReplaceAll(v, string(filepath.Separator), "-")
+ if v == "." || v == ".." {
+ return ""
+ }
+ return v
+}
+
+func Duplicates(items []Item) map[string][]string {
+ byName := map[string][]string{}
+ for _, it := range items {
+ name := it.Suffix
+ if it.KeepAsIs {
+ name = filepath.Base(it.Source)
+ }
+ name = strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
+ byName[name] = append(byName[name], filepath.Base(it.Source))
+ }
+ out := map[string][]string{}
+ for k, v := range byName {
+ if len(v) > 1 {
+ out[k] = v
+ }
+ }
+ return out
+}
diff --git a/internal/names/names_test.go b/internal/names/names_test.go
@@ -0,0 +1,129 @@
+package names
+
+import (
+ "strings"
+ "testing"
+)
+
+func sample() Session {
+ return Session{
+ Album: "Test Album",
+ AlbumDir: "test-album",
+ ArtistDir: "test-artist",
+ Tracks: []Item{
+ {Source: "/s/01 a.flac", Prefix: "1-01_", Suffix: "first-light.flac", Original: "First Light", Number: 1, Disc: 1},
+ {Source: "/s/02 b.flac", Prefix: "1-02_", Suffix: "second-wind.flac", Original: "Second Wind", Number: 2, Disc: 1},
+ },
+ }
+}
+
+func TestRenderParseRoundTrip(t *testing.T) {
+ s := sample()
+ text := Render(s)
+ if !strings.Contains(text, "album: test-album") || !strings.Contains(text, "artist: test-artist") {
+ t.Fatalf("rendered file missing the folder lines:\n%s", text)
+ }
+ if !strings.Contains(text, "1-01_first-light.flac") {
+ t.Fatalf("rendered file missing a track line:\n%s", text)
+ }
+ got, err := Parse(text, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Tracks) != 2 || got.Tracks[0].Full() != "1-01_first-light.flac" {
+ t.Errorf("round trip changed the names: %+v", got.Tracks)
+ }
+ if got.AlbumDir != "test-album" || got.ArtistDir != "test-artist" {
+ t.Errorf("round trip changed the folders: %+v", got)
+ }
+}
+
+func TestParseEditedFolderNames(t *testing.T) {
+ s := sample()
+ edited := "album: yoake\nartist: shiina-ringo\n\n1-01_yoake.flac\n1-02_morning-light.flac\n"
+ got, err := Parse(edited, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.AlbumDir != "yoake" || got.ArtistDir != "shiina-ringo" {
+ t.Errorf("folders not applied: %+v", got)
+ }
+ if got.Album != "yoake" || !got.AlbumSet {
+ t.Errorf("album tag should follow the edited folder name, got %q set=%v", got.Album, got.AlbumSet)
+ }
+ if got.Tracks[0].Full() != "1-01_yoake.flac" {
+ t.Errorf("track name not applied: %q", got.Tracks[0].Full())
+ }
+}
+
+func TestParseEditedName(t *testing.T) {
+ s := sample()
+ got, err := Parse("1-01_夜明け.flac\n1-02_second-wind.flac\n", s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Tracks[0].Full() != "1-01_夜明け.flac" {
+ t.Errorf("CJK name not preserved: %q", got.Tracks[0].Full())
+ }
+}
+
+func TestParseKeepsOriginalComment(t *testing.T) {
+ s := sample()
+ got, err := Parse("1-01_custom-name.flac # First Light\n1-02_second-wind.flac\n", s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Tracks[0].Full() != "1-01_custom-name.flac" {
+ t.Errorf("comment leaked into the name: %q", got.Tracks[0].Full())
+ }
+}
+
+func TestParseKeepAsIs(t *testing.T) {
+ s := sample()
+ got, err := Parse("=\n1-02_second-wind.flac\n", s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Tracks[0].KeepAsIs || got.Tracks[0].Full() != "01 a.flac" {
+ t.Errorf("'=' should keep the source filename, got %+v", got.Tracks[0])
+ }
+}
+
+func TestParseDeletedLineSkipsTrack(t *testing.T) {
+ s := sample()
+ got, err := Parse("1-01_first-light.flac\n", s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Tracks) != 1 {
+ t.Errorf("a deleted line should drop that track, got %d items", len(got.Tracks))
+ }
+}
+
+func TestParseErrors(t *testing.T) {
+ s := sample()
+ cases := []struct{ name, text string }{
+ {"missing prefix", "first-light.flac\n"},
+ {"unknown prefix", "1-99_nope.flac\n"},
+ {"empty name", "1-01_\n"},
+ {"path separator", "1-01_a/b.flac\n"},
+ {"duplicate prefix", "1-01_x.flac\n1-01_y.flac\n"},
+ {"all comments", "# nothing here\n"},
+ {"empty album", "album:\n"},
+ }
+ for _, c := range cases {
+ if _, err := Parse(c.text, s); err == nil {
+ t.Errorf("%s: expected an error for %q", c.name, c.text)
+ }
+ }
+}
+
+func TestDuplicates(t *testing.T) {
+ items := []Item{
+ {Source: "/s/a.flac", Prefix: "1-01_", Suffix: "same.flac", Number: 1},
+ {Source: "/s/b.flac", Prefix: "1-02_", Suffix: "same.flac", Number: 2},
+ }
+ if d := Duplicates(items); len(d) != 1 {
+ t.Errorf("expected one duplicate group, got %v", d)
+ }
+}
diff --git a/internal/normalize/normalize.go b/internal/normalize/normalize.go
@@ -0,0 +1,177 @@
+package normalize
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/unicode/norm"
+)
+
+// portableSymbols survive in a name. They are legal on ext4, APFS, NTFS and
+// exFAT, and in SMB and NFS shares, so there is nothing to gain by flattening
+// them. They do need quoting in a shell, which is the price of a title staying
+// readable.
+const portableSymbols = "()[]+&"
+
+// hostileSymbols are illegal on at least one filesystem people actually use --
+// Windows forbids every one of them -- so they cannot be kept as they are.
+const hostileSymbols = `<>:"/\|?*`
+
+func Name(s string) string {
+ runes := []rune(norm.NFKD.String(strings.TrimSpace(s)))
+
+ var b strings.Builder
+ sep := false
+ // emit writes one separator and collapses a run of them.
+ emit := func(c rune) {
+ if sep || b.Len() == 0 {
+ return
+ }
+ b.WriteRune(c)
+ sep = true
+ }
+ for i, r := range runes {
+ switch {
+ case r == '\'' || r == '\u2019' || r == '\u02bc':
+ continue
+ case unicode.Is(unicode.Mn, r):
+ continue
+ case r < 128 && ((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')):
+ b.WriteRune(r)
+ sep = false
+ case r < 128 && r >= 'A' && r <= 'Z':
+ b.WriteRune(r + ('a' - 'A'))
+ sep = false
+ case r < 128 && strings.ContainsRune(portableSymbols, r):
+ b.WriteRune(r)
+ sep = false
+ case r < 128 && strings.ContainsRune(hostileSymbols, r):
+ // Inside a word it becomes an underscore, so while(1<2) reads as
+ // while(1_2) rather than while(1-2). Next to a space it is just
+ // another separator, so "Album: Title" stays album-title instead of
+ // running the two words together as album_title.
+ if embeddedInWord(runes, i) {
+ emit('_')
+ } else {
+ emit('-')
+ }
+ default:
+ emit('-')
+ }
+ }
+ out := strings.Trim(b.String(), "-_")
+ // A name that is only punctuation is no more usable than an empty one, and
+ // the plan already knows how to ask the user for a real name. Without this
+ // a title of "()" would quietly create a directory called "()".
+ if !strings.ContainsFunc(out, func(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
+ }) {
+ return ""
+ }
+ return out
+}
+
+// embeddedInWord reports whether the rune at i has non-space characters on both
+// sides of it.
+func embeddedInWord(runes []rune, i int) bool {
+ if i == 0 || i+1 >= len(runes) {
+ return false
+ }
+ return !unicode.IsSpace(runes[i-1]) && !unicode.IsSpace(runes[i+1])
+}
+
+func TrackFile(disc, track, width int, title, ext string) string {
+ if ext != "" && !strings.HasPrefix(ext, ".") {
+ ext = "." + ext
+ }
+ return fmt.Sprintf("%d-%0*d_%s%s", disc, width, track, Name(title), ext)
+}
+
+func PadWidth(maxTrack int) int {
+ switch {
+ case maxTrack < 100:
+ return 2
+ case maxTrack < 1000:
+ return 3
+ default:
+ return 4
+ }
+}
+
+func DirName(s string) string { return Name(s) }
+
+type Collision struct {
+ Target string
+ Causes []string
+}
+
+func (c Collision) Error() string {
+ return fmt.Sprintf("normalisation collision on %q from %s", c.Target, strings.Join(c.Causes, ", "))
+}
+
+func TrackName(target string) string {
+ parts := strings.SplitN(target, "_", 2)
+ if len(parts) != 2 {
+ return target
+ }
+ name := parts[1]
+ if i := strings.LastIndexByte(name, '.'); i >= 0 {
+ name = name[:i]
+ }
+ return name
+}
+
+func DetectCollisions(items [][2]string) []Collision {
+ byName := map[string][]string{}
+ var order []string
+ for _, it := range items {
+ src, name := it[0], TrackName(it[1])
+ if _, ok := byName[name]; !ok {
+ order = append(order, name)
+ }
+ dup := false
+ for _, existing := range byName[name] {
+ if existing == src {
+ dup = true
+ break
+ }
+ }
+ if !dup {
+ byName[name] = append(byName[name], src)
+ }
+ }
+ var out []Collision
+ for _, name := range order {
+ if len(byName[name]) > 1 {
+ out = append(out, Collision{Target: name, Causes: byName[name]})
+ }
+ }
+ return out
+}
+
+func Truncate(name string, max int) (string, bool) {
+ if len(name) <= max {
+ return name, false
+ }
+ ext := ""
+ if i := strings.LastIndexByte(name, '.'); i >= 0 {
+ if len(name)-i <= 6 {
+ ext = name[i:]
+ name = name[:i]
+ }
+ }
+ budget := max - len(ext)
+ if budget < 1 {
+ budget = 1
+ }
+ cut := name
+ if len(cut) > budget {
+ cut = cut[:budget]
+ if i := strings.LastIndexByte(cut, '-'); i > 0 {
+ cut = cut[:i]
+ }
+ }
+ cut = strings.Trim(cut, "-")
+ return cut + ext, true
+}
diff --git a/internal/normalize/normalize_test.go b/internal/normalize/normalize_test.go
@@ -0,0 +1,111 @@
+package normalize
+
+import "testing"
+
+func TestName(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"The Method to Love", "the-method-to-love"},
+ {"Prophecy At 1420 MHz", "prophecy-at-1420-mhz"},
+ {"Don't Stop", "dont-stop"},
+ {"Don\u2019t Stop", "dont-stop"},
+ {"Caf\u00e9 M\u00fcnster", "cafe-munster"},
+ {"MIXED Case TiTlE", "mixed-case-title"},
+ {" leading and trailing ", "leading-and-trailing"},
+ {"trailing dots...", "trailing-dots"},
+ {"\u591c\u660e\u3051", ""},
+ {"\u2605\u2605\u2605\u2605\u2605", ""},
+ {"", ""},
+ {"---", ""},
+ {"1", "1"},
+
+ // Symbols that every filesystem accepts are kept, so a title stays
+ // readable instead of collapsing into a run of dashes.
+ {"Track (feat. Someone) [Remix]", "track-(feat-someone)-[remix]"},
+ {"Bj\u00f6rk & Sigur R\u00f3s", "bjork-&-sigur-ros"},
+ {"while(1<2), Part 1", "while(1_2)-part-1"},
+
+ // A symbol that is illegal somewhere becomes an underscore inside a
+ // word, so the two characters it sat between stay separated rather than
+ // turning into two words.
+ {"a<b", "a_b"},
+ {"Foo*Bar", "foo_bar"},
+ {"Path/With/Slashes", "path_with_slashes"},
+
+ // Next to a space it is just a separator, or the words either side run
+ // together as album_the.
+ {"\u00c1lbum: The (Deluxe) Edition!", "album-the-(deluxe)-edition"},
+ {"Foo: Bar", "foo-bar"},
+ {"Foo < Bar", "foo-bar"},
+
+ // Nothing is left dangling at either end.
+ {"Foo<", "foo"},
+ {"<Foo", "foo"},
+ {"()", ""},
+ }
+ for _, c := range cases {
+ if got := Name(c.in); got != c.want {
+ t.Errorf("Name(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestPadWidth(t *testing.T) {
+ cases := []struct {
+ max, want int
+ }{
+ {1, 2}, {9, 2}, {99, 2}, {100, 3}, {120, 3}, {999, 3}, {1000, 4},
+ }
+ for _, c := range cases {
+ if got := PadWidth(c.max); got != c.want {
+ t.Errorf("PadWidth(%d) = %d, want %d", c.max, got, c.want)
+ }
+ }
+}
+
+func TestTrackFile(t *testing.T) {
+ if got := TrackFile(1, 1, 2, "The Method to Love", ".flac"); got != "1-01_the-method-to-love.flac" {
+ t.Errorf("2-digit: got %q", got)
+ }
+ if got := TrackFile(1, 7, 3, "Long Player Track 7", ".flac"); got != "1-007_long-player-track-7.flac" {
+ t.Errorf("3-digit: got %q", got)
+ }
+ if got := TrackFile(2, 10, 2, "X", "flac"); got != "2-10_x.flac" {
+ t.Errorf("ext: got %q", got)
+ }
+}
+
+func TestDetectCollisions(t *testing.T) {
+ items := [][2]string{
+ {"First", "1-01_same.flac"},
+ {"Second", "1-02_same.flac"},
+ {"Other", "1-03_other.flac"},
+ }
+ got := DetectCollisions(items)
+ if len(got) != 1 {
+ t.Fatalf("want 1 collision, got %d (%v)", len(got), got)
+ }
+ if got[0].Target != "same" || len(got[0].Causes) != 2 {
+ t.Errorf("unexpected collision: %+v", got[0])
+ }
+ dup := [][2]string{{"Same", "1-01_same.flac"}, {"Same", "1-01_same.flac"}}
+ if c := DetectCollisions(dup); len(c) != 0 {
+ t.Errorf("identical sources should not collide, got %v", c)
+ }
+}
+
+func TestTruncate(t *testing.T) {
+ long := "1-01_" + string(make([]byte, 0)) + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.flac"
+ got, cut := Truncate(long, 200)
+ if !cut {
+ t.Fatal("expected truncation")
+ }
+ if len(got) > 200 {
+ t.Errorf("truncated name is %d bytes, want <= 200", len(got))
+ }
+ if ext := got[len(got)-5:]; ext != ".flac" {
+ t.Errorf("extension not preserved: %q", got)
+ }
+ if s, cut := Truncate("short.flac", 200); cut || s != "short.flac" {
+ t.Errorf("short name should be untouched, got %q cut=%v", s, cut)
+ }
+}
diff --git a/internal/plan/plan.go b/internal/plan/plan.go
@@ -0,0 +1,661 @@
+package plan
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "intake/internal/names"
+ "intake/internal/normalize"
+ "intake/internal/scan"
+ "intake/internal/tags"
+)
+
+type Issue struct {
+ Hard bool
+ Text string
+}
+
+type TrackPlan struct {
+ Source string
+ Title string
+ Artist string
+ Disc int
+ Number int
+ Target string
+ Ext string
+ Duration float64
+ Original string
+ Truncated bool
+ Tags []tags.Note
+}
+
+type Plan struct {
+ Album scan.Album
+ AlbumName string
+ AlbumTag string
+ AlbumArt string
+ CreditFromTags string
+ Artist string
+ Year string
+ VariousArt bool
+ AlbumDir string
+ ArtistDir string
+ ArchiveDir string
+ LibraryDir string
+ ArchiveRoot string
+ LibraryRoot string
+ Tracks []TrackPlan
+ Issues []Issue
+ Dropped []string
+ Warnings []string
+}
+
+func (p Plan) Blocked() bool {
+ for _, i := range p.Issues {
+ if i.Hard {
+ return true
+ }
+ }
+ return false
+}
+
+const (
+ variousArtists = "Various Artists"
+ specialVA = "various-artists"
+)
+
+type resolved struct {
+ title string
+ disc int
+ track int
+ artist string
+ src string
+ orig string
+ dur float64
+}
+
+type DirKey struct {
+ Dir string
+ Credit string
+ Source string
+}
+
+func Build(a scan.Album, archiveRoot, libraryRoot string, artistKey DirKey) Plan {
+ p := Plan{Album: a, ArchiveRoot: archiveRoot, LibraryRoot: libraryRoot}
+ if len(a.Tracks) == 0 {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: "no readable audio tracks"})
+ return p
+ }
+
+ albumName, albumNameOK := commonValue(a, tags.FieldAlbum)
+ albumArtist, albumArtistOK := commonValue(a, tags.FieldAlbumArtist)
+ artist, artistOK := commonValue(a, tags.FieldArtist)
+ yearRaw, _ := commonValue(a, tags.FieldDate)
+
+ if !albumArtistOK {
+ switch {
+ case artistOK:
+ albumArtist = artist
+ p.Warnings = append(p.Warnings, fmt.Sprintf("no ALBUMARTIST tag; using ARTIST %q", artist))
+ default:
+ if dominant, n, total := dominantArtist(a); n*2 > total {
+ albumArtist = dominant
+ p.Warnings = append(p.Warnings, fmt.Sprintf("no ALBUMARTIST tag and the artists differ; %d of %d tracks credit %q, using that", n, total, dominant))
+ } else if inferred, _, ok := splitDirName(a.Name); ok {
+ albumArtist = inferred
+ p.Warnings = append(p.Warnings, fmt.Sprintf("no artist tags at all; inferred artist %q from the directory name", inferred))
+ }
+ }
+ }
+ if !albumNameOK {
+ if _, inferred, ok := splitDirName(a.Name); ok {
+ albumName = inferred
+ p.Warnings = append(p.Warnings, fmt.Sprintf("no ALBUM tag; inferred %q from the directory name", inferred))
+ }
+ }
+
+ // Only when nothing above found an album artist does differing per-track
+ // artists mean a compilation. An album with guest tracks still has one album
+ // artist, and that is what ALBUMARTIST is for.
+ if strings.TrimSpace(albumArtist) == "" && differingArtists(a) {
+ p.VariousArt = true
+ albumArtist = variousArtists
+ p.Warnings = append(p.Warnings, "per-track artists differ and there is no album artist; filing under various-artists")
+ }
+
+ if strings.TrimSpace(albumArtist) == "" {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: "album artist is unknown: no ALBUMARTIST/ARTIST tag and the directory name gives no hint"})
+ }
+ if strings.TrimSpace(albumName) == "" {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: "album name is unknown: no ALBUM tag and the directory name gives no hint"})
+ }
+
+ p.AlbumName = albumName
+ p.AlbumTag = albumName
+ p.AlbumArt = albumArtist
+ p.CreditFromTags = albumArtist
+ p.Year = tags.NormalizeYear(yearRaw)
+ if yearRaw != "" && p.Year == "" {
+ p.Warnings = append(p.Warnings, fmt.Sprintf("could not read a year from DATE=%q", yearRaw))
+ }
+
+ dropped := map[string]bool{}
+ for _, t := range a.Tracks {
+ for _, d := range tags.Dropped(t.Tags) {
+ dropped[d] = true
+ }
+ }
+ for d := range dropped {
+ p.Dropped = append(p.Dropped, d)
+ }
+ sort.Strings(p.Dropped)
+
+ var items []resolved
+ maxTrack := 0
+ missingNumbers := 0
+ for _, t := range a.Tracks {
+ base := filepath.Base(t.Path)
+ fDisc, fTrack, fTitle := scan.FromFilename(base)
+
+ title, ok := tags.Get(t.Tags, tags.FieldTitle)
+ if !ok || strings.TrimSpace(title) == "" {
+ title = fTitle
+ if title != "" {
+ p.Warnings = append(p.Warnings, fmt.Sprintf("%s: no TITLE tag; using the file name %q", base, title))
+ }
+ }
+ original := title
+
+ num := 0
+ if v, ok := tags.Get(t.Tags, tags.FieldTrackNumber); ok {
+ fmt.Sscanf(strings.TrimSpace(v), "%d", &num)
+ }
+ if num == 0 && fTrack > 0 {
+ num = fTrack
+ p.Warnings = append(p.Warnings, fmt.Sprintf("%s: no TRACKNUMBER tag; inferring track %d from the file name", base, num))
+ }
+ if num == 0 {
+ missingNumbers++
+ }
+
+ disc := 0
+ if v, ok := tags.Get(t.Tags, tags.FieldDiscNumber); ok {
+ fmt.Sscanf(strings.TrimSpace(v), "%d", &disc)
+ }
+ if disc == 0 && fDisc > 0 {
+ disc = fDisc
+ }
+ if disc == 0 {
+ disc = 1
+ }
+
+ trArtist, _ := tags.Get(t.Tags, tags.FieldArtist)
+ if strings.TrimSpace(trArtist) == "" {
+ trArtist = albumArtist
+ }
+ if num > maxTrack {
+ maxTrack = num
+ }
+ if strings.TrimSpace(title) == "" {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: fmt.Sprintf("%s: no usable title (tag and file name are empty)", base)})
+ }
+ items = append(items, resolved{title: title, disc: disc, track: num, artist: trArtist, src: t.Path, orig: original, dur: t.Duration})
+ }
+
+ if missingNumbers > 0 {
+ p.Warnings = append(p.Warnings, fmt.Sprintf("%d track(s) have no track number; they will be numbered in file order", missingNumbers))
+ used := map[int]bool{}
+ for _, it := range items {
+ if it.track > 0 {
+ used[it.track] = true
+ }
+ }
+ next := 1
+ for i := range items {
+ if items[i].track != 0 {
+ continue
+ }
+ for used[next] {
+ next++
+ }
+ items[i].track = next
+ used[next] = true
+ next++
+ }
+ for _, it := range items {
+ if it.track > maxTrack {
+ maxTrack = it.track
+ }
+ }
+ }
+
+ width := normalize.PadWidth(maxTrack)
+
+ tracksOnDisc := map[int]int{}
+ for _, it := range items {
+ tracksOnDisc[it.disc]++
+ }
+ discTotal := len(tracksOnDisc)
+
+ var pairs [][2]string
+ for _, it := range items {
+ target := normalize.TrackFile(it.disc, it.track, width, it.title, filepath.Ext(it.src))
+ pairs = append(pairs, [2]string{it.orig, target})
+ }
+ for _, c := range normalize.DetectCollisions(pairs) {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: fmt.Sprintf("two different titles normalise to the same name %q (from %s); rename one before continuing",
+ c.Target, strings.Join(quoteAll(c.Causes), " and "))})
+ }
+
+ for _, it := range items {
+ name := normalize.TrackFile(it.disc, it.track, width, it.title, filepath.Ext(it.src))
+ truncated := false
+ if n, cut := normalize.Truncate(name, 200); cut {
+ p.Warnings = append(p.Warnings, fmt.Sprintf("target name was too long and has been shortened: %s -> %s", name, n))
+ name, truncated = n, true
+ }
+ tp := TrackPlan{
+ Source: it.src,
+ Title: it.title,
+ Artist: it.artist,
+ Disc: it.disc,
+ Number: it.track,
+ Target: name,
+ Ext: filepath.Ext(it.src),
+ Duration: it.dur,
+ Original: it.orig,
+ Truncated: truncated,
+ }
+ trackTotal := tracksOnDisc[it.disc]
+ tp.Tags = []tags.Note{
+ {Field: tags.FieldTrackNumber, Value: fmt.Sprintf("%d", it.track)},
+ {Field: tags.FieldTrackTotal, Value: fmt.Sprintf("%d", trackTotal)},
+ {Field: tags.FieldLegacyTrackTotal, Value: fmt.Sprintf("%d", trackTotal)},
+ {Field: tags.FieldDiscNumber, Value: fmt.Sprintf("%d", it.disc)},
+ {Field: tags.FieldDiscTotal, Value: fmt.Sprintf("%d", discTotal)},
+ {Field: tags.FieldLegacyDiscTotal, Value: fmt.Sprintf("%d", discTotal)},
+ {Field: tags.FieldTitle, Value: strings.TrimSpace(it.title)},
+ {Field: tags.FieldAlbum, Value: strings.TrimSpace(albumName)},
+ {Field: tags.FieldArtist, Value: strings.TrimSpace(it.artist)},
+ {Field: tags.FieldAlbumArtist, Value: strings.TrimSpace(albumArtist)},
+ }
+ if p.Year != "" {
+ tp.Tags = append(tp.Tags, tags.Note{Field: tags.FieldDate, Value: p.Year})
+ }
+ p.Tracks = append(p.Tracks, tp)
+ }
+
+ albumDir := normalize.DirName(p.AlbumName)
+ if albumDir == "" {
+ albumDir = normalize.Name(p.AlbumName)
+ }
+ if albumDir == "" {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: fmt.Sprintf("album name %q has no ASCII folder name; open the editor to supply one", p.AlbumName)})
+ }
+ p.AlbumDir = albumDir
+ artistDir := artistKey.Dir
+ if artistDir == "" {
+ artistDir = normalize.DirName(p.AlbumArt)
+ }
+ p.ArtistDir = artistDir
+ if artistDir == "" {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: fmt.Sprintf("album artist %q has no ASCII folder name; open the editor to supply one", p.AlbumArt)})
+ }
+ if artistDir != "" && albumDir != "" {
+ p.ArchiveDir = filepath.Join(archiveRoot, artistDir, albumDir)
+ p.LibraryDir = filepath.Join(libraryRoot, artistDir, albumDir)
+ }
+ return p
+}
+
+func quoteAll(in []string) []string {
+ out := make([]string, 0, len(in))
+ for _, s := range in {
+ out = append(out, fmt.Sprintf("%q", s))
+ }
+ return out
+}
+
+func splitDirName(dir string) (artist, album string, ok bool) {
+ name := strings.TrimSpace(dir)
+ for _, open := range []string{"[", "("} {
+ if i := strings.Index(name, open); i > 0 {
+ closeCh := map[string]string{"[": "]", "(": ")"}[open]
+ if j := strings.Index(name, closeCh); j > i && strings.TrimSpace(name[j+1:]) == "" {
+ name = strings.TrimSpace(name[:i])
+ }
+ }
+ }
+ name = strings.TrimSpace(strings.TrimSuffix(name, "- flac"))
+ name = strings.TrimSpace(strings.TrimSuffix(name, "- FLAC"))
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", "", false
+ }
+
+ if a, rest, found := strings.Cut(name, " - "); found {
+ a, rest = strings.TrimSpace(a), strings.TrimSpace(rest)
+ if a != "" && rest != "" {
+ return a, rest, true
+ }
+ }
+ return "", "", false
+}
+
+func (p Plan) EditItems() []names.Item {
+ return p.EditSession().Tracks
+}
+
+func (p Plan) EditSession() names.Session {
+ items := make([]names.Item, 0, len(p.Tracks))
+ for _, t := range p.Tracks {
+ prefix, suffix := splitTarget(t.Target)
+ items = append(items, names.Item{
+ Source: t.Source,
+ Prefix: prefix,
+ Suffix: suffix,
+ Suggested: suffix,
+ Original: t.Title,
+ Number: t.Number,
+ Disc: t.Disc,
+ })
+ }
+ return names.Session{
+ Album: p.AlbumTag,
+ AlbumDir: p.AlbumDir,
+ ArtistDir: p.ArtistDir,
+ Tracks: items,
+ }
+}
+
+func (p Plan) nameSession() names.Session {
+ s := p.EditSession()
+ if s.ArtistDir == "" && p.AlbumArt != "" {
+ s.ArtistDir = normalize.DirName(p.AlbumArt)
+ }
+ return s
+}
+
+func (p *Plan) ApplyNames(res names.Result) {
+ byPath := map[string]names.Item{}
+ for _, it := range res.Tracks {
+ byPath[it.Source] = it
+ }
+ for i := range p.Tracks {
+ it, ok := byPath[p.Tracks[i].Source]
+ if !ok {
+ continue
+ }
+ p.Tracks[i].Target = it.Full()
+ renamed := res.TrackEdited[it.Source]
+ if renamed && !it.KeepAsIs {
+ p.Tracks[i].Title = titleFromSuffix(it.Suffix)
+ }
+ for j := range p.Tracks[i].Tags {
+ switch p.Tracks[i].Tags[j].Field {
+ case tags.FieldTitle:
+ if renamed {
+ p.Tracks[i].Tags[j].Value = p.Tracks[i].Title
+ }
+ case tags.FieldTrackNumber:
+ p.Tracks[i].Tags[j].Value = fmt.Sprintf("%d", it.Number)
+ }
+ }
+ }
+
+ if res.AlbumSet && res.AlbumUserEdited {
+ p.AlbumName = res.Album
+ for i := range p.Tracks {
+ for j := range p.Tracks[i].Tags {
+ if p.Tracks[i].Tags[j].Field == tags.FieldAlbum {
+ p.Tracks[i].Tags[j].Value = res.Album
+ }
+ }
+ }
+ } else if res.AlbumSet && p.AlbumName == "" {
+ p.AlbumName = res.Album
+ for i := range p.Tracks {
+ for j := range p.Tracks[i].Tags {
+ if p.Tracks[i].Tags[j].Field == tags.FieldAlbum {
+ p.Tracks[i].Tags[j].Value = res.Album
+ }
+ }
+ }
+ }
+ if res.ArtistUserEdited {
+ p.AlbumArt = res.ArtistDir
+ for i := range p.Tracks {
+ for j := range p.Tracks[i].Tags {
+ if p.Tracks[i].Tags[j].Field == tags.FieldAlbumArtist {
+ p.Tracks[i].Tags[j].Value = res.ArtistDir
+ } else if p.Tracks[i].Tags[j].Field == tags.FieldArtist &&
+ strings.TrimSpace(p.Tracks[i].Tags[j].Value) == "" {
+ p.Tracks[i].Tags[j].Value = res.ArtistDir
+ }
+ }
+ }
+ }
+
+ if res.AlbumDir != "" {
+ p.AlbumDir = res.AlbumDir
+ }
+ if res.ArtistDir != "" {
+ p.ArtistDir = res.ArtistDir
+ }
+ p.rebuildPaths()
+ p.reassessIssues(res.AlbumUserEdited, res.ArtistUserEdited)
+ var pairs [][2]string
+ for _, t := range p.Tracks {
+ pairs = append(pairs, [2]string{t.Title, t.Target})
+ }
+ for _, c := range normalize.DetectCollisions(pairs) {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: fmt.Sprintf("two different titles normalise to the same name %q (from %s); rename one before continuing",
+ c.Target, strings.Join(quoteAll(c.Causes), " and "))})
+ }
+}
+
+func (p *Plan) reassessIssues(albumEdited, artistEdited bool) {
+ if albumEdited || p.AlbumName != "" {
+ p.Issues = removeIssues(p.Issues, "album name is unknown")
+ }
+ if artistEdited || p.AlbumArt != "" {
+ p.Issues = removeIssues(p.Issues, "album artist is unknown")
+ }
+ if p.AlbumDir != "" {
+ p.Issues = removeIssues(p.Issues, "no ASCII folder name; open the editor to supply one")
+ }
+ if p.ArtistDir != "" {
+ p.Issues = removeIssues(p.Issues, `album artist "`)
+ }
+ p.Issues = removeIssues(p.Issues, "normalise to the same name")
+}
+
+func BuildWithArtist(a scan.Album, archiveRoot, libraryRoot string, key DirKey, edited *Plan, session *names.Session) Plan {
+ p := Build(a, archiveRoot, libraryRoot, key)
+ if edited == nil {
+ return p
+ }
+ var editedItems []names.Item
+ if session != nil {
+ editedItems = session.Tracks
+ } else {
+ editedItems = edited.EditItems()
+ }
+ bySource := map[string]names.Item{}
+ for _, it := range editedItems {
+ bySource[it.Source] = it
+ }
+ if edited.AlbumName != "" {
+ p.AlbumName = edited.AlbumName
+ p.AlbumTag = edited.AlbumName
+ for i := range p.Tracks {
+ for j := range p.Tracks[i].Tags {
+ if p.Tracks[i].Tags[j].Field == tags.FieldAlbum {
+ p.Tracks[i].Tags[j].Value = edited.AlbumName
+ }
+ }
+ }
+ }
+ if edited.AlbumArt != "" && strings.TrimSpace(edited.CreditFromTags) == "" {
+ oldArt := edited.AlbumArt
+ p.AlbumArt = edited.AlbumArt
+ for i := range p.Tracks {
+ for j := range p.Tracks[i].Tags {
+ switch p.Tracks[i].Tags[j].Field {
+ case tags.FieldAlbumArtist:
+ p.Tracks[i].Tags[j].Value = edited.AlbumArt
+ case tags.FieldArtist:
+ if v := strings.TrimSpace(p.Tracks[i].Tags[j].Value); v == "" || v == oldArt {
+ p.Tracks[i].Tags[j].Value = edited.AlbumArt
+ }
+ }
+ }
+ }
+ }
+ for i := range p.Tracks {
+ item, ok := bySource[p.Tracks[i].Source]
+ if !ok {
+ continue
+ }
+ target := item.Full()
+ if target == "" {
+ continue
+ }
+ p.Tracks[i].Target = target
+ if item.Suffix != item.Suggested {
+ _, suffix := splitTarget(target)
+ p.Tracks[i].Title = titleFromSuffix(suffix)
+ for k := range p.Tracks[i].Tags {
+ if p.Tracks[i].Tags[k].Field == tags.FieldTitle {
+ p.Tracks[i].Tags[k].Value = p.Tracks[i].Title
+ }
+ }
+ }
+ }
+ if key.Dir != "" {
+ p.ArtistDir = key.Dir
+ }
+ p.rebuildPaths()
+ p.reassessIssues(true, true)
+ var pairs [][2]string
+ for _, t := range p.Tracks {
+ pairs = append(pairs, [2]string{t.Title, t.Target})
+ }
+ for _, c := range normalize.DetectCollisions(pairs) {
+ p.Issues = append(p.Issues, Issue{Hard: true, Text: fmt.Sprintf("two different titles normalise to the same name %q (from %s); rename one before continuing",
+ c.Target, strings.Join(quoteAll(c.Causes), " and "))})
+ }
+ return p
+}
+
+func (p *Plan) rebuildPaths() {
+ if p.AlbumDir == "" {
+ p.AlbumDir = normalize.DirName(p.AlbumName)
+ if p.AlbumDir == "" {
+ p.AlbumDir = normalize.Name(p.AlbumName)
+ }
+ }
+ if p.ArtistDir == "" {
+ p.ArtistDir = normalize.DirName(p.AlbumArt)
+ }
+ if p.ArchiveRoot == "" || p.LibraryRoot == "" {
+ return
+ }
+ if p.ArtistDir != "" && p.AlbumDir != "" {
+ p.ArchiveDir = filepath.Join(p.ArchiveRoot, p.ArtistDir, p.AlbumDir)
+ p.LibraryDir = filepath.Join(p.LibraryRoot, p.ArtistDir, p.AlbumDir)
+ }
+}
+
+func removeIssues(in []Issue, contains string) []Issue {
+ out := make([]Issue, 0, len(in))
+ for _, i := range in {
+ if !strings.Contains(i.Text, contains) {
+ out = append(out, i)
+ }
+ }
+ return out
+}
+
+func splitTarget(target string) (prefix, suffix string) {
+ if i := strings.IndexByte(target, '_'); i >= 0 {
+ return target[:i+1], target[i+1:]
+ }
+ return "", target
+}
+
+func titleFromSuffix(suffix string) string {
+ s := strings.TrimSuffix(suffix, filepath.Ext(suffix))
+ s = strings.ReplaceAll(s, "-", " ")
+ return strings.TrimSpace(s)
+}
+
+func commonValue(a scan.Album, field string) (string, bool) {
+ var value string
+ found := false
+ for _, t := range a.Tracks {
+ v, ok := tags.Get(t.Tags, field)
+ if !ok || strings.TrimSpace(v) == "" {
+ return "", false
+ }
+ if !found {
+ value, found = v, true
+ continue
+ }
+ if v != value {
+ return "", false
+ }
+ }
+ return value, found
+}
+
+// dominantArtist returns the most common non-empty ARTIST across an album's
+// tracks, how many tracks carry it, and how many carry any artist at all.
+//
+// This is how an album with guest tracks is told apart from a compilation when
+// there is no ALBUMARTIST to say so. Matching whole credit strings is deliberate:
+// "Deadmau5 + Imogen Heap" is a different credit from "Deadmau5", and a solo
+// album with two features still has a clear majority. Splitting credits on a
+// separator would mean guessing whether it is "+", "&", "feat." or "with".
+func dominantArtist(a scan.Album) (string, int, int) {
+ counts := map[string]int{}
+ total := 0
+ for _, t := range a.Tracks {
+ v, ok := tags.Get(t.Tags, tags.FieldArtist)
+ v = strings.TrimSpace(v)
+ if !ok || v == "" {
+ continue
+ }
+ counts[v]++
+ total++
+ }
+ best, n := "", 0
+ for v, c := range counts {
+ if c > n || (c == n && v < best) {
+ best, n = v, c
+ }
+ }
+ return best, n, total
+}
+
+// differingArtists reports whether the tracks credit more than one artist.
+func differingArtists(a scan.Album) bool {
+ first := ""
+ for _, t := range a.Tracks {
+ v, ok := tags.Get(t.Tags, tags.FieldArtist)
+ v = strings.TrimSpace(v)
+ if !ok || v == "" {
+ continue
+ }
+ if first == "" {
+ first = v
+ continue
+ }
+ if v != first {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go
@@ -0,0 +1,228 @@
+package plan
+
+import (
+ "testing"
+
+ "intake/internal/audio"
+ "intake/internal/scan"
+ "intake/internal/tags"
+)
+
+func track(name string, number, disc int, title string) audio.Track {
+ notes := []tags.Note{
+ {Field: tags.FieldTitle, Value: title},
+ {Field: tags.FieldAlbum, Value: "An Album"},
+ {Field: tags.FieldAlbumArtist, Value: "An Artist"},
+ {Field: tags.FieldArtist, Value: "An Artist"},
+ {Field: tags.FieldTrackNumber, Value: itoa(number)},
+ {Field: tags.FieldDiscNumber, Value: itoa(disc)},
+ }
+ return audio.Track{Path: "/staging/" + name, Ext: ".flac", Tags: notes}
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var b []byte
+ for n > 0 {
+ b = append([]byte{byte('0' + n%10)}, b...)
+ n /= 10
+ }
+ return string(b)
+}
+
+func planFor(tracks []audio.Track) Plan {
+ return Build(scan.Album{
+ Dir: "/staging/an-album",
+ Name: "an-album",
+ Status: scan.StatusReady,
+ Tracks: tracks,
+ }, "/archive", "/library", DirKey{})
+}
+
+func value(t *testing.T, p Plan, i int, field string) string {
+ t.Helper()
+ if i >= len(p.Tracks) {
+ t.Fatalf("no track %d in the plan", i)
+ }
+ v, ok := tags.Get(p.Tracks[i].Tags, field)
+ if !ok {
+ t.Fatalf("track %d has no %s", i, field)
+ }
+ return v
+}
+
+func TestTotalsAreWrittenForASingleDisc(t *testing.T) {
+ p := planFor([]audio.Track{
+ track("a.flac", 1, 1, "One"),
+ track("b.flac", 2, 1, "Two"),
+ track("c.flac", 3, 1, "Three"),
+ })
+ for i := 0; i < 3; i++ {
+ for _, f := range []string{tags.FieldTrackTotal, tags.FieldLegacyTrackTotal} {
+ if got := value(t, p, i, f); got != "3" {
+ t.Errorf("track %d %s = %q, want 3", i, f, got)
+ }
+ }
+ for _, f := range []string{tags.FieldDiscTotal, tags.FieldLegacyDiscTotal} {
+ if got := value(t, p, i, f); got != "1" {
+ t.Errorf("track %d %s = %q, want 1", i, f, got)
+ }
+ }
+ }
+}
+
+func TestTrackTotalCountsWithinEachDisc(t *testing.T) {
+ p := planFor([]audio.Track{
+ track("d1a.flac", 1, 1, "One"),
+ track("d1b.flac", 2, 1, "Two"),
+ track("d2a.flac", 1, 2, "Three"),
+ track("d2b.flac", 2, 2, "Four"),
+ track("d2c.flac", 3, 2, "Five"),
+ })
+ want := []string{"2", "2", "3", "3", "3"}
+ for i, w := range want {
+ if got := value(t, p, i, tags.FieldTrackTotal); got != w {
+ t.Errorf("track %d TRACKTOTAL = %q, want %q", i, got, w)
+ }
+ if got := value(t, p, i, tags.FieldDiscTotal); got != "2" {
+ t.Errorf("track %d DISCTOTAL = %q, want 2", i, got)
+ }
+ }
+}
+
+func TestTotalsAreComputedNotCopied(t *testing.T) {
+ tracks := []audio.Track{
+ track("a.flac", 1, 1, "One"),
+ track("b.flac", 2, 1, "Two"),
+ }
+ for i := range tracks {
+ tracks[i].Tags = append(tracks[i].Tags,
+ tags.Note{Field: tags.FieldTrackTotal, Value: "99"},
+ tags.Note{Field: tags.FieldLegacyTrackTotal, Value: "99"},
+ tags.Note{Field: tags.FieldDiscTotal, Value: "7"},
+ tags.Note{Field: tags.FieldLegacyDiscTotal, Value: "7"},
+ )
+ }
+ p := planFor(tracks)
+ for i := 0; i < 2; i++ {
+ if got := value(t, p, i, tags.FieldTrackTotal); got != "2" {
+ t.Errorf("track %d TRACKTOTAL = %q, want the computed 2", i, got)
+ }
+ if got := value(t, p, i, tags.FieldDiscTotal); got != "1" {
+ t.Errorf("track %d DISCTOTAL = %q, want the computed 1", i, got)
+ }
+ }
+}
+
+func TestTotalsAreNotReportedAsDropped(t *testing.T) {
+ p := planFor([]audio.Track{track("a.flac", 1, 1, "One")})
+ dropped := tags.Dropped(p.Tracks[0].Tags)
+ for _, d := range dropped {
+ switch d {
+ case tags.FieldTrackTotal, tags.FieldDiscTotal,
+ tags.FieldLegacyTrackTotal, tags.FieldLegacyDiscTotal:
+ t.Errorf("%s is written by intake but reported as dropped", d)
+ }
+ }
+}
+
+// credit makes a track with a given per-track artist, and optionally no
+// ALBUMARTIST at all, which is the shape most of these cases care about.
+func credit(name string, number int, artist string, albumArtist string) audio.Track {
+ notes := []tags.Note{
+ {Field: tags.FieldTitle, Value: name},
+ {Field: tags.FieldAlbum, Value: "An Album"},
+ {Field: tags.FieldArtist, Value: artist},
+ {Field: tags.FieldTrackNumber, Value: itoa(number)},
+ {Field: tags.FieldDiscNumber, Value: "1"},
+ }
+ if albumArtist != "" {
+ notes = append(notes, tags.Note{Field: tags.FieldAlbumArtist, Value: albumArtist})
+ }
+ return audio.Track{Path: "/staging/" + name + ".flac", Ext: ".flac", Tags: notes}
+}
+
+// An explicit ALBUMARTIST is authoritative. It exists so an album with guest
+// tracks still has one album artist, so a track crediting someone else must not
+// turn the album into a compilation.
+func TestExplicitAlbumArtistSurvivesGuestTracks(t *testing.T) {
+ p := planFor([]audio.Track{
+ credit("a", 1, "Deadmau5", "Deadmau5"),
+ credit("b", 2, "Deadmau5 + Wolfgang Gartner", "Deadmau5"),
+ credit("c", 3, "Deadmau5 + Imogen Heap", "Deadmau5"),
+ })
+ if p.VariousArt {
+ t.Error("an album with guest tracks was treated as a compilation")
+ }
+ if p.AlbumArt != "Deadmau5" {
+ t.Errorf("album artist = %q, want Deadmau5", p.AlbumArt)
+ }
+ if p.Blocked() {
+ t.Errorf("plan is blocked: %v", p.Issues)
+ }
+}
+
+// With no ALBUMARTIST, a clear majority per-track artist is the album artist.
+// Whole credit strings are compared, so "+" needs no special handling.
+func TestDominantArtistIsUsedWhenAlbumArtistIsMissing(t *testing.T) {
+ var tracks []audio.Track
+ for i := 1; i <= 11; i++ {
+ tracks = append(tracks, credit("solo", i, "Deadmau5", ""))
+ }
+ tracks = append(tracks,
+ credit("guest1", 12, "Deadmau5 + Wolfgang Gartner", ""),
+ credit("guest2", 13, "Deadmau5 + Imogen Heap", ""),
+ )
+ p := planFor(tracks)
+ if p.VariousArt {
+ t.Error("a solo album with two features was treated as a compilation")
+ }
+ if p.AlbumArt != "Deadmau5" {
+ t.Errorf("album artist = %q, want Deadmau5", p.AlbumArt)
+ }
+}
+
+// A real compilation has no majority, so it still goes to various-artists
+// rather than refusing to proceed.
+func TestAllDistinctArtistsStillMeansVarious(t *testing.T) {
+ p := planFor([]audio.Track{
+ credit("a", 1, "Aphex Twin", ""),
+ credit("b", 2, "Boards of Canada", ""),
+ credit("c", 3, "Autechre", ""),
+ credit("d", 4, "Squarepusher", ""),
+ })
+ if !p.VariousArt {
+ t.Error("four different artists should be a compilation")
+ }
+ if p.AlbumArt != "Various Artists" {
+ t.Errorf("album artist = %q, want Various Artists", p.AlbumArt)
+ }
+ if p.Blocked() {
+ t.Errorf("a compilation should not be blocked: %v", p.Issues)
+ }
+}
+
+// The tag spelling in the file must not change what it means. This is the bug
+// that made a deadmau5 album unresolvable: ALBUM ARTIST was stripped as an
+// unknown tag while the album artist looked like it was never tagged.
+func TestVariantAlbumArtistSpellingResolves(t *testing.T) {
+ tracks := []audio.Track{
+ credit("a", 1, "Deadmau5", ""),
+ credit("b", 2, "Deadmau5", ""),
+ }
+ for i := range tracks {
+ tracks[i].Tags = append(tracks[i].Tags, tags.Note{Field: "ALBUM ARTIST", Value: "Deadmau5"})
+ }
+ p := planFor(tracks)
+ if p.AlbumArt != "Deadmau5" {
+ t.Errorf("album artist = %q, want Deadmau5", p.AlbumArt)
+ }
+ if p.VariousArt {
+ t.Error("variant spelling should not produce a compilation")
+ }
+ if p.Blocked() {
+ t.Errorf("plan is blocked: %v", p.Issues)
+ }
+}
diff --git a/internal/scan/scan.go b/internal/scan/scan.go
@@ -0,0 +1,172 @@
+package scan
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+
+ "intake/internal/audio"
+)
+
+type Status string
+
+const (
+ StatusReady Status = "ready"
+ StatusEmpty Status = "empty"
+ StatusUnreadable Status = "unreadable"
+)
+
+type Album struct {
+ Dir string
+ Name string
+ Status Status
+ Notes []string
+ Tracks []audio.Track
+ Subdirs int
+ TotalDur float64
+ FileCount int
+}
+
+func One(ctx context.Context, dir string) (Album, error) {
+ return album(ctx, dir, Options{Probe: true})
+}
+
+type Options struct {
+ Probe bool
+}
+
+func Staging(ctx context.Context, dir string) ([]Album, error) {
+ return StagingWith(ctx, dir, Options{Probe: true})
+}
+
+func StagingWith(ctx context.Context, dir string, opts Options) ([]Album, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ var albums []Album
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ if strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ a, err := album(ctx, filepath.Join(dir, e.Name()), opts)
+ if err != nil {
+ return nil, err
+ }
+ albums = append(albums, a)
+ }
+ sort.Slice(albums, func(i, j int) bool { return albums[i].Name < albums[j].Name })
+ return albums, nil
+}
+
+func album(ctx context.Context, dir string, opts Options) (Album, error) {
+ a := Album{Dir: dir, Name: filepath.Base(dir)}
+
+ files, err := collectAudio(dir, &a)
+ if err != nil {
+ return a, err
+ }
+ if len(files) == 0 {
+ a.Status = StatusEmpty
+ a.Notes = append(a.Notes, "no audio files found")
+ return a, nil
+ }
+
+ if !opts.Probe {
+ a.Status = StatusReady
+ a.FileCount = len(files)
+ return a, nil
+ }
+
+ a.Status = StatusReady
+ a.FileCount = len(files)
+ skipped := 0
+ for _, p := range files {
+ t, err := audio.Probe(ctx, p)
+ if err != nil {
+ skipped++
+ a.Notes = append(a.Notes, fmt.Sprintf("ignoring unreadable file %s", filepath.Base(p)))
+ continue
+ }
+ a.Tracks = append(a.Tracks, t)
+ a.TotalDur += t.Duration
+ }
+ if len(a.Tracks) == 0 {
+ a.Status = StatusUnreadable
+ a.Notes = append(a.Notes, "no readable audio files")
+ } else if skipped > 0 {
+ a.Notes = append(a.Notes, fmt.Sprintf("%d unreadable file(s) ignored", skipped))
+ }
+ sort.SliceStable(a.Tracks, func(i, j int) bool {
+ return filepath.Base(a.Tracks[i].Path) < filepath.Base(a.Tracks[j].Path)
+ })
+ return a, nil
+}
+
+func collectAudio(dir string, a *Album) ([]string, error) {
+ var files []string
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ for _, e := range entries {
+ p := filepath.Join(dir, e.Name())
+ switch {
+ case e.IsDir():
+ if strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ a.Subdirs++
+ sub, err := os.ReadDir(p)
+ if err != nil {
+ return nil, err
+ }
+ for _, s := range sub {
+ if s.IsDir() || strings.HasPrefix(s.Name(), ".") {
+ continue
+ }
+ if audio.IsAudio(s.Name()) {
+ files = append(files, filepath.Join(p, s.Name()))
+ }
+ }
+ case strings.HasPrefix(e.Name(), "."), strings.HasPrefix(e.Name(), "."):
+ continue
+ default:
+ if audio.IsAudio(e.Name()) {
+ files = append(files, p)
+ }
+ }
+ }
+ return files, nil
+}
+
+var leadingNumber = regexp.MustCompile(`^\s*(?:(\d{1,2})[.\-_ ]+)?(?:(\d{1,3})\s*[.\-_ ]+)?(.*)$`)
+
+func FromFilename(name string) (disc, track int, title string) {
+ base := strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))
+ base = strings.ReplaceAll(base, "_", " ")
+
+ m := leadingNumber.FindStringSubmatch(base)
+ if m == nil {
+ return 0, 0, strings.TrimSpace(base)
+ }
+ switch {
+ case m[1] != "" && m[2] != "":
+ fmt.Sscanf(m[1], "%d", &disc)
+ fmt.Sscanf(m[2], "%d", &track)
+ return disc, track, strings.TrimSpace(m[3])
+ case m[1] != "":
+ var n int
+ fmt.Sscanf(m[1], "%d", &n)
+ return 0, n, strings.TrimSpace(m[3])
+ default:
+ return 0, 0, strings.TrimSpace(m[3])
+ }
+}
diff --git a/internal/state/state.go b/internal/state/state.go
@@ -0,0 +1,94 @@
+package state
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+type Phase string
+
+const (
+ PhaseDiscovered Phase = "discovered"
+ PhaseReviewed Phase = "reviewed"
+ PhaseArchived Phase = "archived"
+ PhaseFailed Phase = "failed"
+)
+
+type Record struct {
+ Source string `json:"source"`
+ AlbumArtist string `json:"album_artist,omitempty"`
+ Album string `json:"album,omitempty"`
+ Phase Phase `json:"phase"`
+ ArchiveDir string `json:"archive_dir,omitempty"`
+ LibraryDir string `json:"library_dir,omitempty"`
+ Tracks int `json:"tracks"`
+ Placeholder bool `json:"placeholder_cover"`
+ LibraryBuilt bool `json:"library_built"`
+ Issues []string `json:"issues,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type Journal struct {
+ Version int `json:"version"`
+ Albums map[string]Record `json:"albums"`
+ path string
+}
+
+func Open(path string) (*Journal, error) {
+ j := &Journal{Version: 1, Albums: map[string]Record{}, path: path}
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return j, nil
+ }
+ return nil, err
+ }
+ if len(data) == 0 {
+ return j, nil
+ }
+ if err := json.Unmarshal(data, j); err != nil {
+ return nil, err
+ }
+ if j.Albums == nil {
+ j.Albums = map[string]Record{}
+ }
+ j.path = path
+ return j, nil
+}
+
+func (j *Journal) Path() string { return j.path }
+
+func (j *Journal) Get(source string) (Record, bool) {
+ r, ok := j.Albums[source]
+ return r, ok
+}
+
+func (j *Journal) Put(r Record) {
+ r.UpdatedAt = time.Now().UTC()
+ j.Albums[r.Source] = r
+}
+
+func (j *Journal) Delete(source string) {
+ delete(j.Albums, source)
+}
+
+func (j *Journal) Save() error {
+ if j.path == "" {
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(j.path), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(j, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := j.path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, j.path)
+}
diff --git a/internal/tags/tags.go b/internal/tags/tags.go
@@ -0,0 +1,158 @@
+package tags
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+const (
+ FieldTrackNumber = "TRACKNUMBER"
+ FieldTrackTotal = "TRACKTOTAL"
+ FieldDiscNumber = "DISCNUMBER"
+ FieldDiscTotal = "DISCTOTAL"
+ FieldTitle = "TITLE"
+ FieldAlbum = "ALBUM"
+ FieldArtist = "ARTIST"
+ FieldAlbumArtist = "ALBUMARTIST"
+ FieldDate = "DATE"
+
+ FieldLegacyTrackTotal = "TOTALTRACKS"
+ FieldLegacyDiscTotal = "TOTALDISCS"
+)
+
+var Keep = []string{
+ FieldTrackNumber,
+ // the totals are counted from the files being written rather than copied, so
+ // they can't disagree with the album. both spellings because players disagree
+ // about which they read.
+ FieldTrackTotal,
+ FieldLegacyTrackTotal,
+ FieldDiscNumber,
+ FieldDiscTotal,
+ FieldLegacyDiscTotal,
+ FieldTitle,
+ FieldAlbum,
+ FieldArtist,
+ FieldAlbumArtist,
+ FieldDate,
+}
+
+type Note struct {
+ Field string
+ Value string
+}
+
+func IsKept(field string) bool {
+ f := normalizeKey(field)
+ for _, k := range Keep {
+ if k == f {
+ return true
+ }
+ }
+ return false
+}
+
+func Parse(lines []string) []Note {
+ var out []Note
+ for _, line := range lines {
+ line = strings.TrimRight(line, "\r")
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ k, v, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ k = strings.ToUpper(strings.TrimSpace(k))
+ if k == "" {
+ continue
+ }
+ out = append(out, Note{Field: k, Value: v})
+ }
+ return out
+}
+
+// normalizeKey folds a raw Vorbis comment key into the form the whitelist uses.
+//
+// Vorbis keys are free-form and the same field turns up written several ways:
+// ALBUM ARTIST and ALBUM_ARTIST are both common in the wild. Without folding the
+// separators away, such a tag matches nothing, gets reported as a tag being
+// removed, and the value it carried looks like it was never in the file at all.
+func normalizeKey(k string) string {
+ k = strings.ToUpper(strings.TrimSpace(k))
+ return strings.NewReplacer(" ", "", "_", "").Replace(k)
+}
+
+func Get(notes []Note, field string) (string, bool) {
+ f := normalizeKey(field)
+ for _, n := range notes {
+ if normalizeKey(n.Field) == f {
+ return n.Value, true
+ }
+ }
+ return "", false
+}
+
+func Dropped(notes []Note) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, n := range notes {
+ if IsKept(n.Field) || seen[n.Field] {
+ continue
+ }
+ seen[n.Field] = true
+ out = append(out, n.Field)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func NormalizeYear(date string) string {
+ date = strings.TrimSpace(date)
+ if date == "" {
+ return ""
+ }
+ var run strings.Builder
+ flush := func() string {
+ s := run.String()
+ run.Reset()
+ if len(s) == 4 {
+ if s[0] == '1' || s[0] == '2' {
+ return s
+ }
+ }
+ return ""
+ }
+ for _, r := range date {
+ if r >= '0' && r <= '9' {
+ if run.Len() == 4 {
+ run.Reset()
+ continue
+ }
+ run.WriteRune(r)
+ continue
+ }
+ if y := flush(); y != "" {
+ return y
+ }
+ }
+ return flush()
+}
+
+func Write(notes []Note) ([]string, error) {
+ seen := map[string]bool{}
+ var lines []string
+ for _, n := range Keep {
+ if seen[n] {
+ continue
+ }
+ v, ok := Get(notes, n)
+ if !ok || strings.TrimSpace(v) == "" {
+ continue
+ }
+ seen[n] = true
+ lines = append(lines, fmt.Sprintf("%s=%s", n, v))
+ }
+ return lines, nil
+}
diff --git a/internal/tags/tags_test.go b/internal/tags/tags_test.go
@@ -0,0 +1,102 @@
+package tags
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestNormalizeYear(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"2007", "2007"},
+ {"2007-03-14", "2007"},
+ {"2007/03/14", "2007"},
+ {"14.03.2007", "2007"},
+ {"2026", "2026"},
+ {"", ""},
+ {"no digits here", ""},
+ {"12345", ""},
+ {"1999-12-31T23:59", "1999"},
+ {"0000", ""},
+ {"1899", "1899"},
+ {"0999", ""},
+ }
+ for _, c := range cases {
+ if got := NormalizeYear(c.in); got != c.want {
+ t.Errorf("NormalizeYear(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestDropped(t *testing.T) {
+ notes := Parse([]string{
+ "ALBUM=X", "TITLE=Y", "GENRE=IDM", "COMMENT=hi", "RATING=5",
+ "LABEL=L", "REPLAYGAIN_TRACK_GAIN=-3 dB", "MOOD=chill", "BPM=120",
+ })
+ got := Dropped(notes)
+ want := []string{"BPM", "COMMENT", "GENRE", "LABEL", "MOOD", "RATING", "REPLAYGAIN_TRACK_GAIN"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Dropped = %v, want %v", got, want)
+ }
+}
+
+func TestParseCaseInsensitiveKeys(t *testing.T) {
+ notes := Parse([]string{"title=Lower", "AlbumArtist=Mixed", "", "# comment", "novalue"})
+ if v, _ := Get(notes, "TITLE"); v != "Lower" {
+ t.Errorf("TITLE lookup failed, got %q", v)
+ }
+ if v, _ := Get(notes, "albumartist"); v != "Mixed" {
+ t.Errorf("ALBUMARTIST lookup failed, got %q", v)
+ }
+ if len(notes) != 2 {
+ t.Errorf("want 2 notes, got %d (%v)", len(notes), notes)
+ }
+}
+
+func TestWriteWhitelist(t *testing.T) {
+ notes := []Note{
+ {Field: "GENRE", Value: "IDM"},
+ {Field: "TITLE", Value: "Song"},
+ {Field: "ALBUM", Value: "Record"},
+ {Field: "ARTIST", Value: " "},
+ {Field: "ALBUMARTIST", Value: "Band"},
+ {Field: "TRACKNUMBER", Value: "3"},
+ {Field: "DATE", Value: "2001"},
+ }
+ got, err := Write(notes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "TRACKNUMBER=3", "TITLE=Song", "ALBUM=Record",
+ "ALBUMARTIST=Band", "DATE=2001",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Write = %v, want %v", got, want)
+ }
+}
+
+// Vorbis keys are free-form, so the same field arrives written several ways.
+// Folding the separators for matching means such a tag is used and is not
+// reported as something being removed.
+func TestVariantKeySpellingsResolve(t *testing.T) {
+ notes := Parse([]string{"ALBUM ARTIST=Deadmau5", "TRACK TOTAL=13", "DISC_TOTAL=2"})
+ for _, c := range []struct{ field, want string }{
+ {FieldAlbumArtist, "Deadmau5"},
+ {FieldTrackTotal, "13"},
+ {FieldDiscTotal, "2"},
+ } {
+ got, ok := Get(notes, c.field)
+ if !ok || got != c.want {
+ t.Errorf("%s = %q, %v; want %q", c.field, got, ok, c.want)
+ }
+ if !IsKept(c.field) {
+ t.Errorf("%s should be kept", c.field)
+ }
+ }
+ for _, d := range Dropped(notes) {
+ switch d {
+ case "ALBUM ARTIST", "TRACK TOTAL", "DISC_TOTAL":
+ t.Errorf("%s was reported as a tag being removed", d)
+ }
+ }
+}
diff --git a/internal/tui/diacritics.go b/internal/tui/diacritics.go
@@ -0,0 +1,54 @@
+// Code generated by scripts/gen-diacritics.py; DO NOT EDIT.
+
+package tui
+
+// charmbracelet/x/ansi/kitty only ships the first six of these and returns
+// the first mark for anything past five, which quietly mis-addresses every
+// cell past column six. this is kitty's full table.
+var placeholderDiacritics = [...]rune{
+ 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F,
+ 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357,
+ 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
+ 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484,
+ 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
+ 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
+ 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611,
+ 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658,
+ 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
+ 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
+ 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733,
+ 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
+ 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE,
+ 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819,
+ 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
+ 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C,
+ 0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87,
+ 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
+ 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D,
+ 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
+ 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
+ 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1,
+ 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9,
+ 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
+ 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
+ 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7,
+ 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
+ 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA,
+ 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2,
+ 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
+ 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D,
+ 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5,
+ 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
+ 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7,
+ 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
+ 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
+ 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243,
+ 0x1D244,
+}
+
+func diacriticAt(i int) rune {
+ if i < 0 || i >= len(placeholderDiacritics) {
+ return placeholderDiacritics[0]
+ }
+ return placeholderDiacritics[i]
+}
diff --git a/internal/tui/editor.go b/internal/tui/editor.go
@@ -0,0 +1,99 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+
+ "intake/internal/names"
+)
+
+type editorFinishedMsg struct{ err error }
+
+func editorCommand() string {
+ for _, k := range []string{"VISUAL", "EDITOR"} {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ }
+ return "vi"
+}
+
+func (m Module) openNameEditor() (tea.Model, tea.Cmd) {
+ if m.pl.ArchiveDir == "" && len(m.pl.Tracks) == 0 {
+ m.status = "nothing to edit yet"
+ return m, nil
+ }
+ session := m.pl.EditSession()
+ if len(session.Tracks) == 0 {
+ m.status = "nothing to edit"
+ return m, nil
+ }
+
+ dir, err := os.MkdirTemp("", "intake-names-")
+ if err != nil {
+ m.status = "could not create a temporary file: " + err.Error()
+ return m, nil
+ }
+ path := filepath.Join(dir, "names.txt")
+ if err := os.WriteFile(path, []byte(names.Render(session)), 0o644); err != nil {
+ m.status = "could not write " + path + ": " + err.Error()
+ return m, nil
+ }
+ m.namesPath = path
+ m.namesSession = &session
+
+ cmd := exec.Command("sh", "-c", editorCommand()+" "+shellQuote(path))
+ return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
+ return editorFinishedMsg{err: err}
+ })
+}
+
+func (m *Module) applyEditedNames() {
+ path, session := m.namesPath, m.namesSession
+ if path == "" || session == nil {
+ return
+ }
+ defer func() {
+ os.Remove(path)
+ m.namesPath, m.namesSession = "", nil
+ }()
+
+ edited, err := os.ReadFile(path)
+ if err != nil {
+ m.status = "could not read the edited names: " + err.Error()
+ return
+ }
+ res, err := names.Parse(string(edited), *session)
+ if err != nil {
+ m.status = err.Error()
+ return
+ }
+ if dupes := names.Duplicates(res.Tracks); len(dupes) > 0 {
+ for target, sources := range dupes {
+ m.status = fmt.Sprintf("two tracks would be named %q (from %s)", target, joinComma(sources))
+ return
+ }
+ }
+ m.pl.ApplyNames(res)
+ m.status = "names updated"
+}
+
+func joinComma(in []string) string {
+ out := ""
+ for i, s := range in {
+ if i > 0 {
+ out += ", "
+ }
+ out += s
+ }
+ return out
+}
+
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
diff --git a/internal/tui/kitty.go b/internal/tui/kitty.go
@@ -0,0 +1,237 @@
+package tui
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ "image/png"
+ "os"
+ "path/filepath"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/BourgeoisBear/rasterm"
+ "github.com/charmbracelet/x/ansi/kitty"
+)
+
+var forceChars = false
+
+type imageMode int
+
+const (
+ imageChars imageMode = iota
+ imageKitty
+ imageNone
+)
+
+const (
+ baseImageID = 7800
+ basePlacementID = 7800
+)
+
+func detectImageMode() imageMode {
+ override := strings.ToLower(strings.TrimSpace(os.Getenv("INTAKE_IMAGES")))
+ if supportsKittyGraphics() {
+ switch override {
+ case "none", "off":
+ return imageNone
+ case "chafa", "chars", "symbols":
+ return imageChars
+ default:
+ return imageKitty
+ }
+ }
+ switch override {
+ case "none", "off":
+ return imageNone
+ default:
+ return imageChars
+ }
+}
+
+func supportsKittyGraphics() bool {
+ if os.Getenv("KITTY_WINDOW_ID") != "" {
+ return true
+ }
+ term := strings.ToLower(os.Getenv("TERM"))
+ if strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
+ return true
+ }
+ switch strings.ToLower(os.Getenv("TERM_PROGRAM")) {
+ case "wezterm", "ghostty", "kitty":
+ return true
+ }
+ return false
+}
+
+type kittyPlacement struct {
+ path string
+ cols, rows int
+ displayCols, displayRows int
+ pixelW, pixelH int
+ tempPath string
+ id, placement int
+}
+
+func (k kittyPlacement) transmit() string {
+ if k.path == "" || k.cols < 2 || k.rows < 1 {
+ return ""
+ }
+ dc, dr := k.cols, k.rows
+ filePath := k.tempPath
+ if filePath == "" {
+ filePath = k.path
+ }
+
+ // t=f not t=t, which would claim a file the terminal is allowed to delete and
+ // doesn't go with f=100. no C=1 either, that's "don't move the cursor" and
+ // only means something for an image placed at the cursor.
+ header := rasterm.KittyImgOpts{
+ DstCols: uint32(dc),
+ DstRows: uint32(dr),
+ ImageId: uint32(k.id),
+ PlacementId: uint32(k.placement),
+ }.ToHeader("a=T", "t=f", "f=100", "U=1", "q=2")
+
+ var b strings.Builder
+ b.WriteString(strings.TrimSuffix(header, ";"))
+ b.WriteString(";")
+ b.WriteString(base64Path(filePath))
+ b.WriteString(rasterm.KITTY_IMG_FTR)
+ return b.String()
+}
+
+func (k kittyPlacement) clear() string {
+ if k.id == 0 {
+ return ""
+ }
+ return fmt.Sprintf("%sa=d,d=i,i=%d,p=%d,q=2%s", kittyEscape, k.id, k.placement, st)
+}
+
+const (
+ kittyEscape = "\x1b_G"
+ st = "\x1b\\"
+)
+
+const placeholderRune = kitty.Placeholder
+
+func placeholderGrid(cols, rows, imageID int) []string {
+ // the image id rides in the foreground colour. a cell is the placeholder rune
+ // plus three diacritics: row, column, and the id's high byte, since a colour
+ // only carries 24 bits. putting the id in extra runes instead does nothing
+ // and the terminal draws the rune as text.
+ high := diacriticAt((imageID >> 24) & 0xFF)
+ idColor := fmt.Sprintf("\x1b[38:2:%d:%d:%dm",
+ (imageID>>16)&0xFF, (imageID>>8)&0xFF, imageID&0xFF)
+
+ out := make([]string, 0, rows)
+ for row := 0; row < rows; row++ {
+ var b strings.Builder
+ b.WriteString(idColor)
+ for col := 0; col < cols; col++ {
+ b.WriteRune(kitty.Placeholder)
+ b.WriteRune(diacriticAt(row))
+ b.WriteRune(diacriticAt(col))
+ b.WriteRune(high)
+ }
+ b.WriteString("\x1b[39m")
+ out = append(out, b.String())
+ }
+ return out
+}
+
+func base64Path(path string) string {
+ return base64.StdEncoding.EncodeToString([]byte(path))
+}
+
+func stageTempImage(src string) string {
+ // f=100 means png and kitty has no jpeg format at all, so anything that isn't
+ // png gets re-encoded. embedded flac art is usually jpeg, so that's the normal
+ // path, not the edge case. a jpeg handed over as-is comes back EBADPNG.
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return ""
+ }
+ _, format, err := image.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ return ""
+ }
+ payload := data
+ if format != "png" || pngExceedsPreview(data) {
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return ""
+ }
+ b := img.Bounds()
+ if dw, dh := fitPixels(b.Dx(), b.Dy(), maxPreviewPixels); dw != b.Dx() || dh != b.Dy() {
+ img = scaleDown(img, dw, dh)
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return ""
+ }
+ payload = buf.Bytes()
+ }
+
+ dir, err := os.MkdirTemp("", "intake-tty-graphics-protocol-")
+ if err != nil {
+ return ""
+ }
+ dst := filepath.Join(dir, "cover.png")
+ if err := os.WriteFile(dst, payload, 0o600); err != nil {
+ os.RemoveAll(dir)
+ return ""
+ }
+ return dst
+}
+
+func imagePixels(path string) (int, int) {
+ f, err := os.Open(path)
+ if err != nil {
+ return 0, 0
+ }
+ defer f.Close()
+ cfg, _, err := image.DecodeConfig(f)
+ if err != nil {
+ return 0, 0
+ }
+ return cfg.Width, cfg.Height
+}
+
+func drawImage(p kittyPlacement) tea.Cmd {
+ seq := p.transmit()
+ if seq == "" {
+ return nil
+ }
+ return tea.Raw(seq)
+}
+
+func clearImage(p kittyPlacement) tea.Cmd {
+ seq := p.clear()
+ if seq == "" {
+ return nil
+ }
+ return tea.Raw(seq)
+}
+
+func fitToAspect(pw, ph, boxCols, boxRows int) (int, int) {
+ if pw <= 0 || ph <= 0 || boxCols < 2 || boxRows < 1 {
+ return boxCols, boxRows
+ }
+ boxHalfW := boxCols * 2
+ if ph*boxHalfW/pw <= boxRows*2 {
+ rows := ph * boxHalfW / pw / 2
+ if rows < 1 {
+ rows = 1
+ }
+ return boxCols, rows
+ }
+ cols := pw * boxRows * 2 / ph / 2
+ if cols < 1 {
+ cols = 1
+ }
+ return cols, boxRows
+}
diff --git a/internal/tui/kitty_test.go b/internal/tui/kitty_test.go
@@ -0,0 +1,336 @@
+package tui
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/colorprofile"
+ "github.com/charmbracelet/x/ansi/kitty"
+)
+
+func TestPlaceholderGridEncodesRowAndColumn(t *testing.T) {
+ const cols, rows = 3, 2
+ grid := placeholderGrid(cols, rows, baseImageID)
+ if len(grid) != rows {
+ t.Fatalf("got %d rows, want %d", len(grid), rows)
+ }
+ for i, row := range grid {
+ runes := []rune(stripSGR(row))
+ if len(runes) != cols*4 {
+ t.Fatalf("row %d has %d runes, want %d", i, len(runes), cols*4)
+ }
+ for col := 0; col < cols; col++ {
+ cell := runes[col*4 : col*4+4]
+ if cell[0] != kitty.Placeholder {
+ t.Errorf("row %d col %d: got %#x, want the placeholder rune", i, col, cell[0])
+ }
+ if cell[1] != kitty.Diacritic(i) {
+ t.Errorf("row %d col %d: row mark = %#x, want %#x", i, col, cell[1], kitty.Diacritic(i))
+ }
+ if cell[2] != kitty.Diacritic(col) {
+ t.Errorf("row %d col %d: column mark = %#x, want %#x", i, col, cell[2], kitty.Diacritic(col))
+ }
+ }
+ }
+}
+
+func TestPlaceholderEncodingMatchesKittenIcat(t *testing.T) {
+ got := []rune(stripSGR(placeholderGrid(3, 1, baseImageID)[0]))
+ want := []rune{
+ 0x10EEEE, 0x0305, 0x0305, 0x0305,
+ 0x10EEEE, 0x0305, 0x030D, 0x0305,
+ 0x10EEEE, 0x0305, 0x030E, 0x0305,
+ }
+ if len(got) != len(want) {
+ t.Fatalf("got %#x, want %#x", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("rune %d = %#x, want %#x", i, got[i], want[i])
+ }
+ }
+}
+
+func TestTransmissionIsAValidVirtualPlacement(t *testing.T) {
+ p := kittyPlacement{path: "/music/covers/cover.jpg", cols: 30, rows: 15, id: baseImageID, placement: basePlacementID}
+ seq := p.transmit()
+
+ if !strings.HasPrefix(seq, kittyEscape) {
+ t.Fatalf("must start with the APC introducer, got %q", seq[:min(10, len(seq))])
+ }
+ if strings.Count(seq, kittyEscape) != 1 {
+ t.Errorf("the introducer must appear exactly once: %q", seq)
+ }
+ if !strings.HasSuffix(seq, st) {
+ t.Error("must be terminated with ST")
+ }
+ for _, want := range []string{
+ "a=T",
+ "f=100",
+ "t=f",
+ "U=1",
+ "c=30",
+ "r=15",
+ "i=7800",
+ "p=7800",
+ "q=2",
+ } {
+ if !strings.Contains(seq, want) {
+ t.Errorf("transmission is missing %q: %s", want, seq)
+ }
+ }
+ if strings.Contains(seq, "C=1") {
+ t.Errorf("a virtual placement must not set the cursor policy: %s", seq)
+ }
+ payload := seq[strings.Index(seq, ";")+1 : len(seq)-len(st)]
+ decoded, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ t.Fatalf("path payload is not valid base64: %v", err)
+ }
+ if string(decoded) != p.path {
+ t.Errorf("path = %q, want %q", decoded, p.path)
+ }
+}
+
+func TestClearKeepsTheImageCached(t *testing.T) {
+ p := kittyPlacement{path: "/x.jpg", cols: 10, rows: 5, id: baseImageID, placement: basePlacementID}
+ seq := p.clear()
+ if !strings.Contains(seq, "d=i") {
+ t.Errorf("clear should use the lowercase delete form: %s", seq)
+ }
+ if !strings.Contains(seq, "i=7800") || !strings.Contains(seq, "p=7800") {
+ t.Errorf("clear must target the placement: %s", seq)
+ }
+}
+
+func TestEmptyPlacementProducesNothing(t *testing.T) {
+ for _, p := range []kittyPlacement{
+ {path: "", cols: 10, rows: 5, id: 1},
+ {path: "/x.jpg", cols: 1, rows: 5, id: 1},
+ {path: "/x.jpg", cols: 10, rows: 0, id: 1},
+ } {
+ if seq := p.transmit(); seq != "" {
+ t.Errorf("expected no sequence for %+v, got %q", p, seq)
+ }
+ }
+ if seq := (kittyPlacement{}).clear(); seq != "" {
+ t.Errorf("clearing an unplaced image should produce nothing, got %q", seq)
+ }
+}
+
+func TestGraphicsPreviewQueuesAPlacementMatchingItsGrid(t *testing.T) {
+ forceChars = false
+ p := &ImagePreviewer{mode: imageKitty, cache: map[string][]string{}, staged: map[string]string{}}
+ dir := t.TempDir()
+ img := dir + "/cover.png"
+ makeImage(t, img, false)
+
+ grid := p.Render(img, 30, 20)
+ if len(grid) == 0 {
+ t.Fatal("graphics mode should still reserve cells")
+ }
+ place, ok := p.TakePlacement()
+ if !ok {
+ t.Fatal("graphics mode should queue a placement")
+ }
+ if place.path != img {
+ t.Errorf("placement path = %q, want %q", place.path, img)
+ }
+ if n := strings.Count(grid[0], string(kitty.Placeholder)); n != place.cols {
+ t.Errorf("placement is %d columns but the grid has %d cells", place.cols, n)
+ }
+ if len(grid) != place.rows {
+ t.Errorf("placement is %d rows but the grid has %d", place.rows, len(grid))
+ }
+ if _, ok := p.TakePlacement(); ok {
+ t.Error("taking a placement twice should be empty the second time")
+ }
+}
+
+func TestCharacterModeEmitsNoEscapes(t *testing.T) {
+ forceChars = true
+ defer func() { forceChars = false }()
+ p := NewImagePreviewer()
+ if !p.Available() {
+ t.Skip("chafa is not installed")
+ }
+ dir := t.TempDir()
+ img := dir + "/cover.png"
+ makeImage(t, img, false)
+ lines := p.Render(img, 40, 20)
+ if len(lines) == 0 {
+ t.Fatal("character mode produced no preview")
+ }
+ if strings.Contains(lines[0], kittyEscape) {
+ t.Error("character mode must not emit graphics escapes")
+ }
+ if _, ok := p.TakePlacement(); ok {
+ t.Error("character mode must not queue a placement")
+ }
+}
+
+func TestCellShapeMatchesReference(t *testing.T) {
+ grid := placeholderGrid(2, 1, 8)
+ body := grid[0]
+ body = stripSGR(body)
+ got := []rune(body)
+ want := []rune{
+ 0x10EEEE, 0x0305, 0x0305, 0x0305,
+ 0x10EEEE, 0x0305, 0x030D, 0x0305,
+ }
+ if len(got) != len(want) {
+ t.Fatalf("cell runes = %#x, want %#x", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("rune %d = %#x, want %#x", i, got[i], want[i])
+ }
+ }
+}
+
+func TestGridIndexesRowsAndColumns(t *testing.T) {
+ grid := placeholderGrid(3, 2, 8)
+ for row, line := range grid {
+ cells := []rune(stripSGR(line))
+ for col := 0; col < 3; col++ {
+ cell := cells[col*4 : col*4+4]
+ if cell[1] != kittyDiacritic(row) {
+ t.Errorf("cell (%d,%d) row mark = %#x, want %#x", row, col, cell[1], kittyDiacritic(row))
+ }
+ if cell[2] != kittyDiacritic(col) {
+ t.Errorf("cell (%d,%d) col mark = %#x, want %#x", row, col, cell[2], kittyDiacritic(col))
+ }
+ }
+ }
+}
+
+func TestImageIDTravelsInForegroundColour(t *testing.T) {
+ cases := []struct {
+ id int
+ want string
+ }{
+ {8, "\x1b[38:2:0:0:8m"},
+ {7800, "\x1b[38:2:0:30:120m"},
+ {0x010203, "\x1b[38:2:1:2:3m"},
+ }
+ for _, c := range cases {
+ grid := placeholderGrid(1, 1, c.id)
+ if !strings.HasPrefix(grid[0], c.want) {
+ t.Errorf("id %d: line starts %q, want prefix %q", c.id, grid[0][:min(20, len(grid[0]))], c.want)
+ }
+ }
+}
+
+func TestHighByteGoesInThirdDiacritic(t *testing.T) {
+ grid := placeholderGrid(1, 1, 0xD7000000)
+ if !strings.HasPrefix(grid[0], "\x1b[38:2:0:0:0m") {
+ t.Errorf("expected a black foreground, got %q", grid[0][:min(20, len(grid[0]))])
+ }
+ cells := []rune(stripSGR(grid[0]))
+ if cells[3] != kittyDiacritic(0xD7) {
+ t.Errorf("msb diacritic = %#x, want %#x", cells[3], kittyDiacritic(0xD7))
+ }
+}
+
+func TestEachLineResetsColour(t *testing.T) {
+ grid := placeholderGrid(2, 3, 8)
+ for row, line := range grid {
+ if !strings.HasSuffix(line, "\x1b[39m") {
+ t.Errorf("row %d does not reset the foreground colour: %q", row, line)
+ }
+ if !strings.HasPrefix(line, "\x1b[38:2:") {
+ t.Errorf("row %d does not set the id colour: %q", row, line)
+ }
+ }
+}
+
+func kittyDiacritic(i int) rune { return kitty.Diacritic(i) }
+
+func stripSGR(s string) string {
+ var b strings.Builder
+ for i := 0; i < len(s); {
+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
+ j := i + 2
+ for j < len(s) && s[j] != 'm' {
+ j++
+ }
+ i = j + 1
+ continue
+ }
+ b.WriteByte(s[i])
+ i++
+ }
+ return b.String()
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func TestDisplayWidthCountsPlaceholderCellsAsOne(t *testing.T) {
+ const cols = 30
+ grid := placeholderGrid(cols, 1, baseImageID)
+ if got := displayWidth(grid[0]); got != cols {
+ t.Errorf("placeholder line measures %d cells, want %d", got, cols)
+ }
+}
+
+func TestDisplayWidthIgnoresEscapesAndCombiningMarks(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want int
+ }{
+ {"escapes are free", "\x1b[31mred\x1b[0m", 3},
+ {"a truecolor sequence", "\x1b[38:2:0:16:146m", 0},
+ {"combining mark adds nothing", "a\u0305b", 2},
+ {"one placeholder cell", "\U0010EEEE\u0305\u0305", 1},
+ {"two placeholder cells", "\U0010EEEE\u0305\u0305\U0010EEEE\u0305\u030D", 2},
+ {"empty", "", 0},
+ }
+ for _, c := range cases {
+ if got := displayWidth(c.in); got != c.want {
+ t.Errorf("%s: displayWidth(%q) = %d, want %d", c.name, c.in, got, c.want)
+ }
+ }
+}
+
+func TestTruncateToLeavesFittingGridAlone(t *testing.T) {
+ grid := placeholderGrid(10, 1, baseImageID)
+ got := truncateTo(grid[0], 10)
+ if got != grid[0] {
+ t.Errorf("a line that fits was rewritten:\n got %q\nwant %q", got, grid[0])
+ }
+ if !strings.Contains(got, "38:2:") {
+ t.Error("the image id colour was stripped from the grid line")
+ }
+ if n := strings.Count(got, string(placeholderRune)); n != 10 {
+ t.Errorf("kept %d cells, want 10", n)
+ }
+}
+
+func TestKittyImagesRequireATrueColourProfile(t *testing.T) {
+ t.Setenv("TERM", "xterm-kitty")
+ t.Setenv("INTAKE_IMAGES", "kitty")
+ profile, ok := requiredColourProfile()
+ if !ok {
+ t.Fatal("kitty graphics mode must impose a colour profile")
+ }
+ if profile != colorprofile.TrueColor {
+ t.Errorf("profile = %v, want truecolor", profile)
+ }
+}
+
+func TestOtherModesLeaveTheProfileAlone(t *testing.T) {
+ for _, mode := range []string{"chafa", "none"} {
+ t.Setenv("TERM", "xterm-kitty")
+ t.Setenv("INTAKE_IMAGES", mode)
+ if _, ok := requiredColourProfile(); ok {
+ t.Errorf("mode %q should not impose a colour profile", mode)
+ }
+ }
+}
diff --git a/internal/tui/preview.go b/internal/tui/preview.go
@@ -0,0 +1,245 @@
+package tui
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+type ImagePreviewer struct {
+ mode imageMode
+ chafa bool
+ mu sync.Mutex
+ cache map[string][]string
+ pending *kittyPlacement
+ staged map[string]string
+}
+
+func NewImagePreviewer() *ImagePreviewer {
+ p := &ImagePreviewer{cache: map[string][]string{}, staged: map[string]string{}}
+ _, err := exec.LookPath("chafa")
+ p.chafa = err == nil
+ p.mode = detectImageMode()
+ if p.mode == imageKitty {
+ return p
+ }
+ if p.mode == imageChars && !p.chafa {
+ p.mode = imageNone
+ }
+ return p
+}
+
+func (p *ImagePreviewer) Available() bool {
+ return p.mode == imageKitty || (p.mode == imageChars && p.chafa)
+}
+
+func (p *ImagePreviewer) Mode() string {
+ switch p.mode {
+ case imageKitty:
+ return "kitty graphics"
+ case imageChars:
+ return "chafa"
+ default:
+ return "none"
+ }
+}
+
+func (p *ImagePreviewer) TakePlacement() (kittyPlacement, bool) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.pending == nil {
+ return kittyPlacement{}, false
+ }
+ out := *p.pending
+ p.pending = nil
+ return out, true
+}
+
+func (p *ImagePreviewer) Render(path string, maxW, maxH int) []string {
+ if path == "" || maxW < 8 || maxH < 3 {
+ return nil
+ }
+ cols, rows := maxW, maxH
+ if cols/2 < rows {
+ rows = cols / 2
+ }
+ if rows < 3 {
+ return nil
+ }
+ if p.mode == imageKitty && !forceChars {
+ pw, ph := imagePixels(path)
+ dispCols, dispRows := fitToAspect(pw, ph, cols, rows)
+ place := kittyPlacement{
+ path: path,
+ cols: cols,
+ rows: rows,
+ displayCols: dispCols,
+ displayRows: dispRows,
+ pixelW: pw,
+ pixelH: ph,
+ id: baseImageID,
+ placement: basePlacementID,
+ }
+ p.mu.Lock()
+ if p.staged == nil {
+ p.staged = map[string]string{}
+ }
+ if staged, ok := p.staged[path]; ok {
+ place.tempPath = staged
+ } else {
+ place.tempPath = stageTempImage(path)
+ p.staged[path] = place.tempPath
+ }
+ p.mu.Unlock()
+
+ if place.tempPath == "" {
+ return p.renderChars(path, cols, rows)
+ }
+ p.pending = &place
+ return placeholderGrid(cols, rows, place.id)
+ }
+ return p.renderChars(path, cols, rows)
+}
+
+func (p *ImagePreviewer) renderChars(path string, cols, rows int) []string {
+ if p.mode == imageNone || !p.chafa {
+ return nil
+ }
+ key := path + "|" + strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
+
+ p.mu.Lock()
+ if cached, ok := p.cache[key]; ok {
+ p.mu.Unlock()
+ return cached
+ }
+ p.mu.Unlock()
+
+ size := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
+ attempts := [][]string{
+ {"--symbols", "block", "--colors", "256", "--fg-only"},
+ {"--symbols", "vhalf+border", "--colors", "16", "--fg-only"},
+ {"--symbols", "ascii", "--colors", "none"},
+ }
+ var lines []string
+ for _, opts := range attempts {
+ out, err := p.run(append(opts, "--size", size, "--stretch", path)...)
+ if err != nil {
+ continue
+ }
+ cand := trimPreview(strings.Split(out, "\n"), cols)
+ if visibleGlyphs(cand) >= minPreviewGlyphs {
+ lines = cand
+ break
+ }
+ }
+
+ p.mu.Lock()
+ p.cache[key] = lines
+ p.mu.Unlock()
+ return lines
+}
+
+const minPreviewGlyphs = 24
+
+func visibleGlyphs(lines []string) int {
+ n := 0
+ for _, l := range lines {
+ for _, r := range stripANSI(l) {
+ if r != ' ' && r != '\t' {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+func (p *ImagePreviewer) run(args ...string) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+ defer cancel()
+
+ full := append([]string{"--format", "symbols"}, args...)
+ cmd := exec.CommandContext(ctx, "chafa", full...)
+ if t := os.Getenv("TERM"); t == "" || t == "dumb" {
+ cmd.Env = append(os.Environ(), "TERM=xterm-256color")
+ }
+ var out, errb bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return "", err
+ }
+ return out.String(), nil
+}
+
+func trimPreview(lines []string, width int) []string {
+ for len(lines) > 0 && strings.TrimSpace(stripANSI(lines[len(lines)-1])) == "" {
+ lines = lines[:len(lines)-1]
+ }
+ for i := len(lines) - 1; i > 0; i-- {
+ if strings.TrimSpace(stripANSI(lines[i])) == "" {
+ lines = lines[i+1:]
+ break
+ }
+ }
+ out := make([]string, 0, len(lines))
+ for _, l := range lines {
+ l = strings.TrimRight(l, " ")
+ if w := displayWidth(l); w > width {
+ l = truncateANSI(l, width)
+ }
+ out = append(out, strings.TrimRight(l, " "))
+ }
+ return out
+}
+
+func stripANSI(s string) string {
+ return ansi.Strip(s)
+}
+
+const cellSentinel = 'x'
+
+func displayWidth(s string) int {
+ // a placeholder cell is one column, but it's a base rune with three combining
+ // diacritics on it and the unicode tables call U+10EEEE three columns wide.
+ // counting runes or trusting the tables measures the grid too wide, and the
+ // layout then clips cells off the end of the image.
+ if !strings.ContainsRune(s, placeholderRune) {
+ return ansi.StringWidth(s)
+ }
+ var b strings.Builder
+ b.Grow(len(s))
+ for i := 0; i < len(s); {
+ r, size := utf8.DecodeRuneInString(s[i:])
+ if r == placeholderRune {
+ b.WriteByte(cellSentinel)
+ i += size
+ for i < len(s) {
+ r2, size2 := utf8.DecodeRuneInString(s[i:])
+ if !unicode.Is(unicode.Mn, r2) {
+ break
+ }
+ i += size2
+ }
+ continue
+ }
+ b.WriteRune(r)
+ i += size
+ }
+ return ansi.StringWidth(b.String())
+}
+
+func truncateANSI(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ return ansi.Truncate(s, width, "")
+}
diff --git a/internal/tui/preview_test.go b/internal/tui/preview_test.go
@@ -0,0 +1,382 @@
+package tui
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "image/png"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "charm.land/bubbletea/v2"
+)
+
+func makeImage(t *testing.T, path string, solid bool) {
+ t.Helper()
+ const n = 300
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ if solid {
+ img.Set(x, y, color.RGBA{0x2b, 0x2b, 0x2b, 0xff})
+ continue
+ }
+ img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 0x80, 0xff})
+ }
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if filepath.Ext(path) == ".png" {
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ if err := jpeg.Encode(f, img, nil); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func countGlyphs(lines []string) int {
+ n := 0
+ for _, l := range lines {
+ for _, r := range stripANSI(l) {
+ if r != ' ' {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+func TestPreviewerDrawsSolidCovers(t *testing.T) {
+ p := NewImagePreviewer()
+ if !p.Available() {
+ t.Skip("chafa is not installed")
+ }
+ dir := t.TempDir()
+
+ solid := filepath.Join(dir, "solid.png")
+ makeImage(t, solid, true)
+ lines := p.Render(solid, 40, 20)
+ if len(lines) == 0 {
+ t.Fatalf("a solid cover produced no preview at all (file=%s)", solid)
+ }
+
+ if n := countGlyphs(lines); n < 20 {
+ t.Errorf("a solid cover rendered only %d glyphs; it must show as blocks", n)
+ }
+
+ detailed := filepath.Join(dir, "detail.jpg")
+ makeImage(t, detailed, false)
+ lines = p.Render(detailed, 40, 20)
+ if n := countGlyphs(lines); n < 20 {
+ t.Errorf("a detailed cover rendered only %d glyphs", n)
+ }
+}
+
+func TestPreviewerNeverExceedsItsBox(t *testing.T) {
+ p := NewImagePreviewer()
+ if !p.Available() {
+ t.Skip("chafa is not installed")
+ }
+ dir := t.TempDir()
+ img := filepath.Join(dir, "x.png")
+ makeImage(t, img, false)
+
+ for _, box := range []struct{ w, h int }{{20, 10}, {40, 20}, {8, 4}, {4, 2}} {
+ lines := p.Render(img, box.w, box.h)
+ if len(lines) > box.h {
+ t.Errorf("box %dx%d: %d lines returned", box.w, box.h, len(lines))
+ }
+ for _, l := range lines {
+ if displayWidth(l) > box.w {
+ t.Errorf("box %dx%d: line is %d cells", box.w, box.h, displayWidth(l))
+ }
+ }
+ }
+}
+
+func TestPreviewerHandlesMissingImage(t *testing.T) {
+ p := NewImagePreviewer()
+ if lines := p.Render(filepath.Join(t.TempDir(), "nope.png"), 40, 20); len(lines) != 0 {
+ t.Error("a missing image should produce no preview, not an error")
+ }
+ if lines := p.Render("", 40, 20); lines != nil {
+ t.Error("an empty path should produce no preview")
+ }
+}
+
+func TestChafaIsOptional(t *testing.T) {
+ p := &ImagePreviewer{cache: map[string][]string{}}
+ if p.Available() {
+ t.Skip("this test only means something without a renderer")
+ }
+ if lines := p.Render("/etc/hostname", 40, 20); lines != nil {
+ t.Error("without a renderer, Render must return nothing")
+ }
+}
+
+var _ = exec.Command
+
+func makeBigImage(t *testing.T, path string) {
+ t.Helper()
+ const n = 400
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ img.Set(x, y, color.RGBA{uint8((x * 7) % 256), uint8((y * 13) % 256), uint8((x * y) % 256), 0xff})
+ }
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStagingConvertsJPEGToPNG(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "cover.jpg")
+ writeJPEG(t, src, 64)
+
+ staged := stageTempImage(src)
+ if staged == "" {
+ t.Fatal("staging a JPEG failed; the cover would not be shown")
+ }
+ defer os.RemoveAll(filepath.Dir(staged))
+
+ data, err := os.ReadFile(staged)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) {
+ t.Fatalf("staged file is not PNG; first bytes %q", data[:min(8, len(data))])
+ }
+ if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
+ t.Fatalf("staged file does not decode: %v", err)
+ }
+ if !bytes.Contains([]byte(staged), []byte("tty-graphics-protocol")) {
+ t.Errorf("staged path %q lacks the marker the protocol requires", staged)
+ }
+}
+
+func TestStagingLeavesPNGAlone(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "cover.png")
+ writePNGFile(t, src, 64)
+
+ before, err := os.ReadFile(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staged := stageTempImage(src)
+ if staged == "" {
+ t.Fatal("staging a PNG failed")
+ }
+ defer os.RemoveAll(filepath.Dir(staged))
+
+ after, err := os.ReadFile(staged)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(before, after) {
+ t.Error("a PNG was recompressed instead of copied")
+ }
+}
+
+func TestStagingRefusesWhatItCannotConvert(t *testing.T) {
+ dir := t.TempDir()
+ junk := filepath.Join(dir, "cover.jpg")
+ if err := os.WriteFile(junk, []byte("this is not an image"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := stageTempImage(junk); got != "" {
+ t.Errorf("staging junk returned %q, want empty", got)
+ }
+ if got := stageTempImage(filepath.Join(dir, "missing.png")); got != "" {
+ t.Errorf("staging a missing file returned %q, want empty", got)
+ }
+}
+
+func TestJPEGPreviewQueuesAUsablePlacement(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "cover.jpg")
+ writeJPEG(t, src, 64)
+
+ prev := &ImagePreviewer{mode: imageKitty}
+ lines := prev.Render(src, 40, 20)
+ if len(lines) == 0 {
+ t.Fatal("a JPEG produced no preview")
+ }
+ place, ok := prev.TakePlacement()
+ if !ok {
+ t.Fatal("a JPEG queued no placement")
+ }
+ if place.tempPath == "" {
+ t.Fatal("the placement has no staged file")
+ }
+ if place.pixelW != 64 || place.pixelH != 64 {
+ t.Errorf("pixels = %dx%d, want 64x64", place.pixelW, place.pixelH)
+ }
+ seq := place.transmit()
+ if !bytes.Contains([]byte(seq), []byte("f=100")) {
+ t.Error("transmission does not declare PNG")
+ }
+}
+
+func writeJPEG(t *testing.T, path string, n int) {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ img.Set(x, y, color.RGBA{uint8(200 - x), uint8(120 + y%100), 0x30, 0xff})
+ }
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := jpeg.Encode(f, img, nil); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func writePNGFile(t *testing.T, path string, n int) {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ img.Set(x, y, color.RGBA{0x11, 0x22, 0x33, 0xff})
+ }
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestFitPixelsKeepsAspect(t *testing.T) {
+ cases := []struct{ w, h, max, wantW, wantH int }{
+ {2000, 2000, 1024, 1024, 1024},
+ {2000, 1000, 1024, 1024, 512},
+ {1000, 2000, 1024, 512, 1024},
+ {800, 600, 1024, 800, 600},
+ {3000, 100, 1024, 1024, 34},
+ {100, 3000, 1024, 34, 1024},
+ }
+ for _, c := range cases {
+ w, h := fitPixels(c.w, c.h, c.max)
+ if w != c.wantW || h != c.wantH {
+ t.Errorf("fitPixels(%d,%d,%d) = %d,%d, want %d,%d", c.w, c.h, c.max, w, h, c.wantW, c.wantH)
+ }
+ if w < 1 || h < 1 {
+ t.Errorf("fitPixels(%d,%d,%d) produced a zero dimension", c.w, c.h, c.max)
+ }
+ }
+}
+
+func TestScaleDownHonoursBothDimensions(t *testing.T) {
+ src := image.NewRGBA(image.Rect(0, 0, 2000, 1000))
+ dw, dh := fitPixels(2000, 1000, maxPreviewPixels)
+ if dw != 1024 || dh != 512 {
+ t.Fatalf("target = %dx%d, want 1024x512", dw, dh)
+ }
+ out := scaleDown(src, dw, dh)
+ b := out.Bounds()
+ if b.Dx() != 1024 || b.Dy() != 512 {
+ t.Errorf("scaled to %dx%d, want 1024x512", b.Dx(), b.Dy())
+ }
+}
+
+func TestScaleDownAverages(t *testing.T) {
+ src := image.NewRGBA(image.Rect(0, 0, 2, 2))
+ src.SetRGBA(0, 0, color.RGBA{255, 255, 255, 255})
+ src.SetRGBA(1, 0, color.RGBA{0, 0, 0, 255})
+ src.SetRGBA(0, 1, color.RGBA{0, 0, 0, 255})
+ src.SetRGBA(1, 1, color.RGBA{255, 255, 255, 255})
+ out := scaleDown(src, 1, 1)
+ r, g, b, _ := out.At(0, 0).RGBA()
+ if r>>8 < 120 || r>>8 > 135 {
+ t.Errorf("average = %d, want about 127", r>>8)
+ }
+ _ = g
+ _ = b
+}
+
+func TestPNGExceedsPreviewReadsTheHeader(t *testing.T) {
+ var small [32]byte
+ copy(small[12:16], "IHDR")
+ small[19] = 100
+ small[23] = 50
+ if pngExceedsPreview(small[:]) {
+ t.Error("a 100x50 header should not be oversized")
+ }
+ var big [32]byte
+ copy(big[12:16], "IHDR")
+ big[16], big[18] = 0x08, 0x00
+ if !pngExceedsPreview(big[:]) {
+ t.Error("a 2048-wide header should be oversized")
+ }
+ if pngExceedsPreview([]byte("not a png at all")) {
+ t.Error("nonsense should not be reported as oversized")
+ }
+}
+
+func TestPlaceCoverEmitsABindableTransmission(t *testing.T) {
+ prev := &ImagePreviewer{mode: imageKitty}
+ m := Module{prev: prev}
+ path := writeTempCover(t)
+ lines := prev.Render(path, 40, 12)
+ if len(lines) == 0 {
+ t.Fatal("the previewer produced no grid")
+ }
+ cmd := m.placeCover()
+ if cmd == nil {
+ t.Fatal("placeCover produced no command")
+ }
+ raw, ok := cmd().(tea.RawMsg)
+ if !ok {
+ t.Fatalf("expected a raw message, got %T", cmd())
+ }
+ seq := fmt.Sprint(raw.Msg)
+
+ for _, want := range []string{"a=T", "t=f", "f=100", "U=1", "q=2", "i=7800", "p=7800"} {
+ if !strings.Contains(seq, want) {
+ t.Errorf("transmission is missing %q: %s", want, seq)
+ }
+ }
+ payload := seq[strings.Index(seq, ";")+1 : strings.LastIndex(seq, st)]
+ decoded, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ t.Fatalf("payload is not valid base64: %v", err)
+ }
+ if !strings.Contains(string(decoded), "tty-graphics-protocol") {
+ t.Errorf("staged path %q is not one the terminal will read", decoded)
+ }
+}
+
+func writeTempCover(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ path := dir + "/cover.png"
+ writeSolidPNG(t, path, 120)
+ return path
+}
diff --git a/internal/tui/runner.go b/internal/tui/runner.go
@@ -0,0 +1,153 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/colorprofile"
+
+ "intake/internal/artwork"
+ "intake/internal/cli"
+ "intake/internal/config"
+ "intake/internal/plan"
+ "intake/internal/scan"
+)
+
+type Runner struct {
+ Cfg config.Config
+ choices map[string]CoverOption
+}
+
+func NewRunner(cfg config.Config) *Runner {
+ return &Runner{Cfg: cfg, choices: map[string]CoverOption{}}
+}
+
+func (r *Runner) Prepare(ctx context.Context, dir string) (plan.Plan, error) {
+ app := r.app(nil)
+ return app.PrepareAlbum(ctx, dir)
+}
+
+func (r *Runner) CoverOptions(ctx context.Context, p plan.Plan) ([]CoverOption, error) {
+ app := r.app(nil)
+ cands, warnings, err := app.CoverCandidates(ctx, p)
+ if err != nil {
+ return nil, err
+ }
+ for _, w := range warnings {
+ _ = w
+ }
+ out := make([]CoverOption, 0, len(cands))
+ for _, c := range cands {
+ out = append(out, CoverOption{
+ Path: c.Path,
+ Source: string(c.Source),
+ Label: c.Label,
+ Width: c.Width,
+ Height: c.Height,
+ Bytes: c.Bytes,
+ Placeholder: c.Source == artwork.SourcePlaceholder,
+ })
+ }
+ return out, nil
+}
+
+func (r *Runner) ApplyCover(p *plan.Plan, choice CoverOption) error {
+ r.choices[p.Album.Dir] = choice
+ return nil
+}
+
+func (r *Runner) Run(ctx context.Context, album scan.Album, progress func(string)) (Outcome, error) {
+ logf := func(format string, args ...any) {
+ if progress != nil {
+ progress(strings.TrimSpace(fmt.Sprintf(format, args...)))
+ }
+ }
+ app := r.app(logf)
+
+ choice, ok := r.choices[album.Dir]
+ if !ok && !r.Cfg.NoCover {
+ res, err := app.FileAlbum(ctx, album.Dir, nil)
+ if err != nil {
+ return Outcome{}, err
+ }
+ delete(r.choices, album.Dir)
+ return outcomeOf(res), nil
+ }
+
+ res, err := app.FileAlbumWithCover(ctx, album.Dir, choice.Path, choice.Placeholder, nil)
+ delete(r.choices, album.Dir)
+ if err != nil {
+ return Outcome{}, err
+ }
+ return outcomeOf(res), nil
+}
+
+func outcomeOf(res cli.FileResult) Outcome {
+ return Outcome{
+ ArchiveDir: res.ArchiveDir,
+ LibraryDir: res.LibraryDir,
+ Files: res.Files,
+ Bytes: res.Bytes,
+ Cover: res.Cover,
+ Placeholder: res.Placeholder,
+ Library: res.Library,
+ Warnings: res.Warnings,
+ }
+}
+
+func (r *Runner) app(logf func(string, ...any)) *cli.App {
+ a := cli.New(r.Cfg, os.Stdout, os.Stderr, os.Stdin)
+ a.DryRun = r.Cfg.DryRun
+ a.Yes = r.Cfg.Yes
+ a.ImagePreview = false
+ if logf != nil {
+ a.Logf = func(line string) { logf("%s", line) }
+ } else {
+ a.Quiet = true
+ }
+ return a
+}
+
+func Run(cfg config.Config, out io.Writer, in io.Reader) error {
+ r := NewRunner(cfg)
+ m := New(Options{
+ Config: cfg,
+ StagingDir: cfg.Staging(),
+ Prepare: r.Prepare,
+ Run: r.Run,
+ CoverOptions: r.CoverOptions,
+ ApplyCover: r.ApplyCover,
+ })
+
+ opts := []tea.ProgramOption{tea.WithInput(in)}
+ if out != nil {
+ opts = append(opts, tea.WithOutput(out))
+ }
+ opts = append(opts, programColourOptions()...)
+ if _, err := tea.NewProgram(m, opts...).Run(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func programColourOptions() []tea.ProgramOption {
+ profile, ok := requiredColourProfile()
+ if !ok {
+ return nil
+ }
+ return []tea.ProgramOption{tea.WithColorProfile(profile)}
+}
+
+func requiredColourProfile() (colorprofile.Profile, bool) {
+ // bubbletea quantises colours to the terminal's profile and the image id rides
+ // in one. at 256 colours id 7800 goes out as palette index 18, which matches
+ // no image, so the cover silently doesn't draw.
+ if detectImageMode() != imageKitty {
+ return 0, false
+ }
+ return colorprofile.TrueColor, true
+}
diff --git a/internal/tui/scale.go b/internal/tui/scale.go
@@ -0,0 +1,188 @@
+package tui
+
+import (
+ "image"
+ "image/color"
+)
+
+const maxPreviewPixels = 1024
+
+func fitPixels(w, h, max int) (int, int) {
+ if w <= max && h <= max {
+ return w, h
+ }
+ if w >= h {
+ nh := h * max / w
+ if nh < 1 {
+ nh = 1
+ }
+ return max, nh
+ }
+ nw := w * max / h
+ if nw < 1 {
+ nw = 1
+ }
+ return nw, max
+}
+
+func scaleDown(src image.Image, dw, dh int) image.Image {
+ // averages blocks instead of dropping pixels, which turns a photo into noise
+ // at preview size. dw/dh are the exact target, not a bound, so a caller that
+ // already worked out the aspect ratio doesn't get it recomputed from one number.
+ b := src.Bounds()
+ sw, sh := b.Dx(), b.Dy()
+ if dw < 1 {
+ dw = 1
+ }
+ if dh < 1 {
+ dh = 1
+ }
+ if dw >= sw && dh >= sh {
+ return src
+ }
+ dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
+
+ switch s := src.(type) {
+ case *image.YCbCr:
+ scaleYCbCr(dst, s, b, sw, sh, dw, dh)
+ case *image.RGBA:
+ scaleRGBA(dst, s, b, sw, sh, dw, dh)
+ case *image.NRGBA:
+ scaleNRGBA(dst, s, b, sw, sh, dw, dh)
+ default:
+ scaleGeneric(dst, src, b, sw, sh, dw, dh)
+ }
+ return dst
+}
+
+func scaleYCbCr(dst *image.RGBA, s *image.YCbCr, b image.Rectangle, sw, sh, dw, dh int) {
+ for y := 0; y < dh; y++ {
+ y0 := b.Min.Y + y*sh/dh
+ y1 := b.Min.Y + (y+1)*sh/dh
+ if y1 <= y0 {
+ y1 = y0 + 1
+ }
+ for x := 0; x < dw; x++ {
+ x0 := b.Min.X + x*sw/dw
+ x1 := b.Min.X + (x+1)*sw/dw
+ if x1 <= x0 {
+ x1 = x0 + 1
+ }
+ var r, g, bl, n uint32
+ for yy := y0; yy < y1; yy++ {
+ for xx := x0; xx < x1; xx++ {
+ c := s.YCbCrAt(xx, yy)
+ cr, cg, cb := color.YCbCrToRGB(c.Y, c.Cb, c.Cr)
+ r += uint32(cr)
+ g += uint32(cg)
+ bl += uint32(cb)
+ n++
+ }
+ }
+ dst.SetRGBA(x, y, color.RGBA{uint8(r / n), uint8(g / n), uint8(bl / n), 0xff})
+ }
+ }
+}
+
+func scaleRGBA(dst *image.RGBA, s *image.RGBA, b image.Rectangle, sw, sh, dw, dh int) {
+ off := func(x, y int) int { return s.PixOffset(x, y) }
+ for y := 0; y < dh; y++ {
+ y0 := b.Min.Y + y*sh/dh
+ y1 := b.Min.Y + (y+1)*sh/dh
+ if y1 <= y0 {
+ y1 = y0 + 1
+ }
+ for x := 0; x < dw; x++ {
+ x0 := b.Min.X + x*sw/dw
+ x1 := b.Min.X + (x+1)*sw/dw
+ if x1 <= x0 {
+ x1 = x0 + 1
+ }
+ var r, g, bl, a, n uint32
+ for yy := y0; yy < y1; yy++ {
+ for xx := x0; xx < x1; xx++ {
+ i := off(xx, yy)
+ r += uint32(s.Pix[i])
+ g += uint32(s.Pix[i+1])
+ bl += uint32(s.Pix[i+2])
+ a += uint32(s.Pix[i+3])
+ n++
+ }
+ }
+ dst.SetRGBA(x, y, color.RGBA{uint8(r / n), uint8(g / n), uint8(bl / n), uint8(a / n)})
+ }
+ }
+}
+
+func scaleNRGBA(dst *image.RGBA, s *image.NRGBA, b image.Rectangle, sw, sh, dw, dh int) {
+ off := func(x, y int) int { return s.PixOffset(x, y) }
+ for y := 0; y < dh; y++ {
+ y0 := b.Min.Y + y*sh/dh
+ y1 := b.Min.Y + (y+1)*sh/dh
+ if y1 <= y0 {
+ y1 = y0 + 1
+ }
+ for x := 0; x < dw; x++ {
+ x0 := b.Min.X + x*sw/dw
+ x1 := b.Min.X + (x+1)*sw/dw
+ if x1 <= x0 {
+ x1 = x0 + 1
+ }
+ var r, g, bl, a, n uint32
+ for yy := y0; yy < y1; yy++ {
+ for xx := x0; xx < x1; xx++ {
+ i := off(xx, yy)
+ al := uint32(s.Pix[i+3])
+ r += uint32(s.Pix[i]) * al / 0xff
+ g += uint32(s.Pix[i+1]) * al / 0xff
+ bl += uint32(s.Pix[i+2]) * al / 0xff
+ a += al
+ n++
+ }
+ }
+ if a == 0 {
+ continue
+ }
+ dst.SetRGBA(x, y, color.RGBA{uint8(r * 0xff / a), uint8(g * 0xff / a), uint8(bl * 0xff / a), uint8(a / n)})
+ }
+ }
+}
+
+func scaleGeneric(dst *image.RGBA, src image.Image, b image.Rectangle, sw, sh, dw, dh int) {
+ for y := 0; y < dh; y++ {
+ y0 := b.Min.Y + y*sh/dh
+ y1 := b.Min.Y + (y+1)*sh/dh
+ if y1 <= y0 {
+ y1 = y0 + 1
+ }
+ for x := 0; x < dw; x++ {
+ x0 := b.Min.X + x*sw/dw
+ x1 := b.Min.X + (x+1)*sw/dw
+ if x1 <= x0 {
+ x1 = x0 + 1
+ }
+ var r, g, bl, a, n uint32
+ for yy := y0; yy < y1; yy++ {
+ for xx := x0; xx < x1; xx++ {
+ cr, cg, cb, ca := src.At(xx, yy).RGBA()
+ r += cr >> 8
+ g += cg >> 8
+ bl += cb >> 8
+ a += ca >> 8
+ n++
+ }
+ }
+ dst.SetRGBA(x, y, color.RGBA{uint8(r / n), uint8(g / n), uint8(bl / n), uint8(a / n)})
+ }
+ }
+}
+
+func pngExceedsPreview(data []byte) bool {
+ const ihdr = 16
+ if len(data) < ihdr+8 || string(data[12:16]) != "IHDR" {
+ return false
+ }
+ w := int(data[16])<<24 | int(data[17])<<16 | int(data[18])<<8 | int(data[19])
+ h := int(data[20])<<24 | int(data[21])<<16 | int(data[22])<<8 | int(data[23])
+ return w > maxPreviewPixels || h > maxPreviewPixels
+}
diff --git a/internal/tui/styles.go b/internal/tui/styles.go
@@ -0,0 +1,264 @@
+package tui
+
+import (
+ "image/color"
+ "os"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
+
+type Theme struct {
+ Dark bool
+
+ Surface color.Color
+ Border color.Color
+ BorderFocus color.Color
+
+ TextBright color.Color
+ Text color.Color
+ TextMuted color.Color
+
+ Accent color.Color
+ Success color.Color
+ Warning color.Color
+ Danger color.Color
+
+ OnAccent color.Color
+}
+
+func ResolveTheme() Theme {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("INTAKE_THEME"))) {
+ case "dark":
+ return makeTheme(true)
+ case "light":
+ return makeTheme(false)
+ }
+ dark := true
+ if v := os.Getenv("COLORFGBG"); v != "" {
+ parts := strings.Split(v, ";")
+ if len(parts) >= 2 {
+ if bg := strings.TrimSpace(parts[len(parts)-1]); bg == "7" || bg == "15" {
+ dark = false
+ }
+ }
+ }
+ return makeTheme(dark)
+}
+
+func makeTheme(dark bool) Theme {
+ c := func(lightHex, darkHex string) color.Color {
+ if dark {
+ return lipgloss.Color(darkHex)
+ }
+ return lipgloss.Color(lightHex)
+ }
+ return Theme{
+ Dark: dark,
+ Surface: c("#F6F7F9", "#16181D"),
+ Border: c("#D6DAE0", "#2A2E36"),
+ BorderFocus: c("#5B3BB5", "#B79CFF"),
+ TextBright: c("#0B0D10", "#FFFFFF"),
+ Text: c("#24292F", "#D6D9DE"),
+ TextMuted: c("#5B6470", "#8B929C"),
+ Accent: c("#5B3BB5", "#B79CFF"),
+ Success: c("#1F7A4D", "#5FD79B"),
+ Warning: c("#8A5A00", "#F0B860"),
+ Danger: c("#B3261E", "#FF8A80"),
+ OnAccent: c("#FFFFFF", "#14161A"),
+ }
+}
+
+type Styles struct {
+ Theme Theme
+
+ AppName lipgloss.Style
+ Crumb lipgloss.Style
+ CrumbDim lipgloss.Style
+ Hint lipgloss.Style
+ HintKey lipgloss.Style
+ HintDesc lipgloss.Style
+ Status lipgloss.Style
+ Error lipgloss.Style
+
+ Title lipgloss.Style
+ Heading lipgloss.Style
+ Label lipgloss.Style
+ Value lipgloss.Style
+ Muted lipgloss.Style
+ Dim lipgloss.Style
+ Code lipgloss.Style
+
+ Accent lipgloss.Style
+ Success lipgloss.Style
+ Warning lipgloss.Style
+ Danger lipgloss.Style
+ Badge lipgloss.Style
+ Pill lipgloss.Style
+
+ Panel lipgloss.Style
+ PanelActive lipgloss.Style
+ Inner lipgloss.Style
+ Rule lipgloss.Style
+
+ Selected lipgloss.Style
+ SelectedDim lipgloss.Style
+ Marker lipgloss.Style
+
+ ListTitle lipgloss.Style
+ ListItem lipgloss.Style
+ ListSelected lipgloss.Style
+ ListDesc lipgloss.Style
+ ListDescSel lipgloss.Style
+ ListFilter lipgloss.Style
+ ListFilterCur lipgloss.Style
+ ListStatusBar lipgloss.Style
+ ListStatusKeys lipgloss.Style
+ ListStatusNums lipgloss.Style
+ ListSpinner lipgloss.Style
+}
+
+func DefaultStyles() Styles { return StylesFor(ResolveTheme()) }
+
+func StylesFor(t Theme) Styles {
+ s := Styles{Theme: t}
+
+ s.AppName = lipgloss.NewStyle().Bold(true).Foreground(t.OnAccent).Background(t.Accent).Padding(0, 1)
+ s.Crumb = lipgloss.NewStyle().Foreground(t.Text)
+ s.CrumbDim = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.Hint = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.HintKey = lipgloss.NewStyle().Foreground(t.Text).Bold(true)
+ s.HintDesc = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.Status = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.Error = lipgloss.NewStyle().Foreground(t.Danger)
+
+ s.Title = lipgloss.NewStyle().Bold(true).Foreground(t.TextBright)
+ s.Heading = lipgloss.NewStyle().Bold(true).Foreground(t.Accent)
+ s.Label = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.Value = lipgloss.NewStyle().Foreground(t.Text)
+ s.Muted = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.Dim = lipgloss.NewStyle().Foreground(t.TextMuted).Faint(true)
+ s.Code = lipgloss.NewStyle().Foreground(t.Text).Background(t.Surface).Padding(0, 1)
+
+ s.Accent = lipgloss.NewStyle().Foreground(t.Accent)
+ s.Success = lipgloss.NewStyle().Foreground(t.Success)
+ s.Warning = lipgloss.NewStyle().Foreground(t.Warning)
+ s.Danger = lipgloss.NewStyle().Foreground(t.Danger)
+ s.Badge = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent).Padding(0, 1)
+ s.Pill = lipgloss.NewStyle().Foreground(t.TextMuted)
+
+ s.Panel = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(t.Border).
+ Padding(0, 1)
+ s.PanelActive = s.Panel.BorderForeground(t.BorderFocus)
+ s.Inner = lipgloss.NewStyle().Padding(0, 1)
+ s.Rule = lipgloss.NewStyle().Foreground(t.Border)
+
+ s.Selected = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent).Bold(true)
+ s.SelectedDim = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent)
+ s.Marker = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+
+ s.ListTitle = lipgloss.NewStyle().Bold(true).Foreground(t.TextBright).Padding(0, 1)
+ s.ListItem = lipgloss.NewStyle().Foreground(t.Text).Padding(0, 1)
+ s.ListSelected = lipgloss.NewStyle().Foreground(t.OnAccent).Background(t.Accent).Bold(true).Padding(0, 1)
+ s.ListDesc = lipgloss.NewStyle().Foreground(t.TextMuted).Padding(0, 1)
+ s.ListDescSel = lipgloss.NewStyle().Foreground(t.OnAccent).Padding(0, 1)
+ s.ListFilter = lipgloss.NewStyle().Foreground(t.Accent)
+ s.ListFilterCur = lipgloss.NewStyle().Foreground(t.TextBright)
+ s.ListStatusBar = lipgloss.NewStyle().Foreground(t.TextMuted)
+ s.ListStatusKeys = lipgloss.NewStyle().Foreground(t.Text).Bold(true)
+ s.ListStatusNums = lipgloss.NewStyle().Foreground(t.Text)
+ s.ListSpinner = lipgloss.NewStyle().Foreground(t.Accent)
+
+ return s
+}
+
+type Box struct{ W, H int }
+
+const chrome = 2
+
+func (m Module) body() Box {
+ h := m.height - chrome
+ if m.railShown() {
+ h--
+ }
+ if h < 3 {
+ h = 3
+ }
+ w := m.width
+ if w < 20 {
+ w = 20
+ }
+ return Box{W: w, H: h}
+}
+
+type mode int
+
+const (
+ modeTiny mode = iota
+ modeMini
+ modeCompact
+ modeFull
+)
+
+func (m Module) mode() mode {
+ switch {
+ case m.height < 8:
+ return modeTiny
+ case m.width < 60 || m.height < 18:
+ return modeMini
+ case m.width < 100 || m.height < 28:
+ return modeCompact
+ default:
+ return modeFull
+ }
+}
+
+func truncateTo(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ if displayWidth(s) <= width {
+ return s
+ }
+ return truncateANSI(s, width)
+}
+
+func clamp(block string, width, height int) string {
+ lines := strings.Split(block, "\n")
+ if len(lines) > height {
+ lines = lines[:height]
+ }
+ for i, l := range lines {
+ lines[i] = truncateTo(l, width)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func fitHeight(block string, height int) string {
+ lines := strings.Split(block, "\n")
+ for len(lines) < height {
+ lines = append(lines, "")
+ }
+ if len(lines) > height {
+ lines = lines[:height]
+ }
+ return strings.Join(lines, "\n")
+}
+
+func panelWith(s Styles, title, body string, width int, active bool) string {
+ style := s.Panel
+ if active {
+ style = s.PanelActive
+ }
+ inner := width - 4
+ if inner < 8 {
+ inner = 8
+ }
+ head := ""
+ if title != "" {
+ head = s.Heading.Render(title) + "\n"
+ }
+ return style.Render(clamp(head+body, inner, 1000))
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
@@ -0,0 +1,980 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "charm.land/bubbles/v2/spinner"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "intake/internal/config"
+ "intake/internal/names"
+ "intake/internal/plan"
+ "intake/internal/scan"
+)
+
+type Screen int
+
+const (
+ ScreenBrowse Screen = iota
+ ScreenReview
+ ScreenCoverPicker
+ ScreenWorking
+ ScreenDone
+)
+
+type Step int
+
+const (
+ StepMetadata Step = iota
+ StepTracks
+ StepCover
+ StepConfirm
+ stepCount
+)
+
+func (s Step) String() string {
+ switch s {
+ case StepMetadata:
+ return "metadata"
+ case StepTracks:
+ return "track names"
+ case StepCover:
+ return "cover art"
+ case StepConfirm:
+ return "confirm"
+ }
+ return "?"
+}
+
+type Options struct {
+ Config config.Config
+ StagingDir string
+ Prepare func(ctx context.Context, dir string) (plan.Plan, error)
+ Run func(ctx context.Context, album scan.Album, progress func(string)) (Outcome, error)
+ CoverOptions func(ctx context.Context, p plan.Plan) ([]CoverOption, error)
+ ApplyCover func(p *plan.Plan, choice CoverOption) error
+}
+
+type Outcome struct {
+ ArchiveDir string
+ LibraryDir string
+ Files int
+ Bytes int64
+ Cover string
+ Placeholder bool
+ Library bool
+ Warnings []string
+}
+
+type CoverOption struct {
+ Path string
+ Source string
+ Label string
+ Width, Height int
+ Bytes int64
+ Placeholder bool
+}
+
+type Module struct {
+ opts Options
+ st Styles
+ prev *ImagePreviewer
+
+ screen Screen
+ step Step
+
+ albums []scan.Album
+ cursor int
+ filter string
+ filtering bool
+ offset int
+
+ current scan.Album
+ pl plan.Plan
+ cover CoverOption
+ hasCover bool
+
+ covers []CoverOption
+ coverCursor int
+ coversFetched bool
+ placed *kittyPlacement
+
+ namesPath string
+ namesSession *names.Session
+
+ events chan tea.Msg
+
+ log []string
+ follow bool
+ spin spinner.Model
+ outcome Outcome
+
+ width, height int
+ status string
+ err error
+ ready bool
+ cancel context.CancelFunc
+ scroll int
+ contentRows int
+}
+
+type albumsMsg struct {
+ albums []scan.Album
+ err error
+}
+
+type planMsg struct {
+ album scan.Album
+ plan plan.Plan
+ err error
+}
+
+type coversMsg struct {
+ covers []CoverOption
+ err error
+}
+
+type doneMsg struct {
+ outcome Outcome
+ err error
+}
+
+type progressMsg string
+
+type replantMsg struct{}
+
+func waitForProgress(ch chan tea.Msg) tea.Cmd {
+ return func() tea.Msg {
+ msg, ok := <-ch
+ if !ok {
+ return nil
+ }
+ return msg
+ }
+}
+
+func New(opts Options) Module {
+ st := DefaultStyles()
+ sp := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(st.ListSpinner))
+ m := Module{
+ opts: opts,
+ st: st,
+ prev: NewImagePreviewer(),
+ spin: sp,
+ status: "reading staging",
+ }
+ return m
+}
+
+func (m Module) Init() tea.Cmd {
+ return tea.Batch(m.spin.Tick, m.scanCmd())
+}
+
+func (m Module) scanCmd() tea.Cmd {
+ dir := m.opts.StagingDir
+ return func() tea.Msg {
+ albums, err := scan.StagingWith(context.Background(), dir, scan.Options{Probe: false})
+ return albumsMsg{albums: albums, err: err}
+ }
+}
+
+func (m Module) planCmd(a scan.Album) tea.Cmd {
+ prepare := m.opts.Prepare
+ return func() tea.Msg {
+ p, err := prepare(context.Background(), a.Dir)
+ return planMsg{album: a, plan: p, err: err}
+ }
+}
+
+func (m Module) coversCmd(p plan.Plan) tea.Cmd {
+ get := m.opts.CoverOptions
+ return func() tea.Msg {
+ covers, err := get(context.Background(), p)
+ return coversMsg{covers: covers, err: err}
+ }
+}
+
+func (m Module) runCmd(ctx context.Context, a scan.Album) tea.Cmd {
+ run := m.opts.Run
+ events := m.events
+ return func() tea.Msg {
+ go func() {
+ defer close(events)
+ outcome, err := run(ctx, a, func(line string) {
+ select {
+ case events <- progressMsg(line):
+ case <-ctx.Done():
+ }
+ })
+ select {
+ case events <- doneMsg{outcome: outcome, err: err}:
+ 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:
+ m.width, m.height = msg.Width, msg.Height
+ return m, nil
+
+ case albumsMsg:
+ m.ready = true
+ if msg.err != nil {
+ m.err = msg.err
+ m.status = "could not read staging"
+ return m, nil
+ }
+ sort.Slice(msg.albums, func(i, j int) bool { return msg.albums[i].Name < msg.albums[j].Name })
+ m.albums = msg.albums
+ m.cursor, m.offset = 0, 0
+ m.status = fmt.Sprintf("%d album(s) staged", len(m.albums))
+ return m, nil
+
+ case planMsg:
+ if msg.err != nil {
+ m.err, m.status = msg.err, msg.err.Error()
+ return m, nil
+ }
+ clear := m.clearPlacedCover()
+ m.resetAlbum()
+ m.current, m.pl = msg.album, msg.plan
+ m.screen, m.step, m.scroll = ScreenReview, StepMetadata, 0
+ m.status = fmt.Sprintf("%d track%s read", len(msg.plan.Tracks), plural(len(msg.plan.Tracks)))
+ return m, clear
+
+ case coversMsg:
+ m.coversFetched = true
+ if msg.err != nil {
+ m.status = msg.err.Error()
+ return m, nil
+ }
+ m.covers = msg.covers
+ m.coverCursor = 0
+ if len(m.covers) == 0 {
+ m.status = "no cover candidates found; continuing without one"
+ m.screen = ScreenReview
+ m.step = StepConfirm
+ return m, nil
+ }
+ m.screen = ScreenCoverPicker
+ m.scroll = 0
+ m.status = fmt.Sprintf("%d candidate%s", len(m.covers), plural(len(m.covers)))
+ m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
+ return m, tea.Batch(m.placeCover(), replantTick())
+
+ case doneMsg:
+ if msg.err != nil {
+ m.err, m.status = msg.err, msg.err.Error()
+ m.screen = ScreenReview
+ return m, nil
+ }
+ m.outcome = msg.outcome
+ m.screen = ScreenDone
+ m.scroll = 0
+ m.status = "filed"
+ return m, nil
+
+ case editorFinishedMsg:
+ if msg.err != nil {
+ m.status = "editor exited with an error: " + msg.err.Error()
+ return m, nil
+ }
+ m.applyEditedNames()
+ return m, nil
+
+ case progressMsg:
+ m.log = append(m.log, string(msg))
+ if len(m.log) > 500 {
+ m.log = m.log[len(m.log)-500:]
+ }
+ if m.follow {
+ // applyScroll clamps this to the last screenful, so the line that
+ // just arrived is the one on screen.
+ m.scroll = 1 << 30
+ }
+ return m, waitForProgress(m.events)
+
+ case replantMsg:
+ if m.screen != ScreenCoverPicker || m.placed == nil {
+ return m, nil
+ }
+ return m, tea.Tick(replantInterval, func(time.Time) tea.Msg { return replantMsg{} })
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spin, cmd = m.spin.Update(msg)
+ return m, cmd
+
+ case tea.KeyPressMsg:
+ return m.handleKey(msg)
+ }
+ return m, nil
+}
+
+func (m Module) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ key := msg.String()
+ if key == "ctrl+c" {
+ return m.quit()
+ }
+
+ switch m.screen {
+ case ScreenBrowse:
+ return m.keyBrowse(key, msg)
+ case ScreenReview:
+ return m.keyReview(key)
+ case ScreenCoverPicker:
+ return m.keyCover(key)
+ case ScreenWorking:
+ switch key {
+ case "q", "esc":
+ return m.quit()
+ case "up", "k":
+ m.follow = false
+ if m.scroll > 0 {
+ m.scroll--
+ }
+ case "down", "j":
+ m.follow = true
+ m.scroll++
+ case "pgup":
+ m.follow = false
+ m.scroll -= m.body().H - 1
+ if m.scroll < 0 {
+ m.scroll = 0
+ }
+ case "pgdown":
+ m.scroll += m.body().H - 1
+ case "home", "g":
+ m.scroll = 0
+ }
+ return m, nil
+ case ScreenDone:
+ switch key {
+ case "q":
+ return m.quit()
+ case "up", "k":
+ if m.scroll > 0 {
+ m.scroll--
+ }
+ return m, nil
+ case "down", "j":
+ m.scroll++
+ return m, nil
+ case "pgup":
+ m.scroll -= m.body().H - 1
+ if m.scroll < 0 {
+ m.scroll = 0
+ }
+ return m, nil
+ case "pgdown":
+ m.scroll += m.body().H - 1
+ return m, nil
+ case "enter", "esc":
+ m.screen = ScreenBrowse
+ m.scroll = 0
+ return m, m.scanCmd()
+ }
+ return m, nil
+ }
+ return m, nil
+}
+
+func (m Module) keyBrowse(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ if m.filtering {
+ switch key {
+ case "enter":
+ m.filtering = false
+ m.cursor, m.offset = 0, 0
+ return m, nil
+ case "esc":
+ m.filtering, m.filter = false, ""
+ m.cursor, m.offset = 0, 0
+ return m, nil
+ case "backspace":
+ if m.filter != "" {
+ m.filter = m.filter[:len(m.filter)-1]
+ }
+ m.cursor, m.offset = 0, 0
+ return m, nil
+ default:
+ if len(msg.Text) > 0 {
+ m.filter += msg.Text
+ m.cursor, m.offset = 0, 0
+ }
+ return m, nil
+ }
+ }
+
+ visible := m.visibleAlbums()
+ switch key {
+ case "q":
+ return m.quit()
+ case "/":
+ m.filtering = true
+ return m, nil
+ case "r":
+ m.status = "refreshing"
+ return m, m.scanCmd()
+ case "up", "k":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ case "down", "j":
+ if m.cursor < len(visible)-1 {
+ m.cursor++
+ }
+ case "home", "g":
+ m.cursor = 0
+ case "end", "G":
+ m.cursor = len(visible) - 1
+ case "enter":
+ if m.cursor < len(visible) {
+ m.status = "reading " + visible[m.cursor].Name
+ return m, m.planCmd(visible[m.cursor])
+ }
+ }
+ return m, nil
+}
+
+func (m Module) keyReview(key string) (tea.Model, tea.Cmd) {
+ switch key {
+ case "up", "k":
+ if m.scroll > 0 {
+ m.scroll--
+ }
+ return m, nil
+ case "down", "j":
+ m.scroll++
+ return m, nil
+ case "pgup":
+ m.scroll -= m.body().H - 1
+ if m.scroll < 0 {
+ m.scroll = 0
+ }
+ return m, nil
+ case "pgdown":
+ m.scroll += m.body().H - 1
+ return m, nil
+ case "home", "g":
+ m.scroll = 0
+ return m, nil
+ case "G":
+ m.scroll = 1 << 20
+ return m, nil
+ }
+
+ switch key {
+ case "q":
+ return m.quit()
+ case "esc":
+ if m.step > StepMetadata {
+ m.step--
+ m.scroll = 0
+ return m, nil
+ }
+ m.screen = ScreenBrowse
+ return m, nil
+ case "e":
+ if m.step == StepTracks || m.step == StepMetadata {
+ return m.openNameEditor()
+ }
+ return m, nil
+ case "enter":
+ return m.nextStep()
+ }
+ return m, nil
+}
+
+func (m Module) nextStep() (tea.Model, tea.Cmd) {
+ switch m.step {
+ case StepMetadata:
+ if m.pl.Blocked() {
+ m.status = "cannot continue: " + firstIssue(m.pl)
+ return m, nil
+ }
+ m.step, m.scroll = StepTracks, 0
+ return m, nil
+ case StepTracks:
+ m.step, m.scroll = StepCover, 0
+ return m, nil
+ case StepCover:
+ if m.opts.CoverOptions == nil {
+ m.step, m.scroll = StepConfirm, 0
+ return m, nil
+ }
+ if !m.coversFetched {
+ m.status = "finding cover art"
+ return m, m.coversCmd(m.pl)
+ }
+ if len(m.covers) == 0 {
+ m.step, m.scroll = StepConfirm, 0
+ return m, nil
+ }
+ m.screen, m.scroll = ScreenCoverPicker, 0
+ return m, nil
+ case StepConfirm:
+ ctx, cancel := context.WithCancel(context.Background())
+ m.cancel = cancel
+ m.events = make(chan tea.Msg, 64)
+ m.screen, m.log = ScreenWorking, nil
+ m.follow = true
+ m.status = "working"
+ return m, tea.Batch(m.spin.Tick, m.runCmd(ctx, m.current))
+ }
+ return m, nil
+}
+
+func (m Module) keyCover(key string) (tea.Model, tea.Cmd) {
+ switch key {
+ case "q":
+ return m.quit()
+ case "esc":
+ m.screen = ScreenReview
+ m.scroll = 0
+ return m, m.clearPlacedCover()
+ case "up", "k":
+ if m.coverCursor > 0 {
+ m.coverCursor--
+ m.scroll = 0
+ m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
+ return m, m.placeCover()
+ }
+ return m, nil
+ case "down", "j":
+ if m.coverCursor < len(m.covers)-1 {
+ m.coverCursor++
+ m.scroll = 0
+ m.renderCoverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
+ return m, m.placeCover()
+ }
+ return m, nil
+ case "enter":
+ if m.coverCursor < len(m.covers) {
+ picked := m.covers[m.coverCursor]
+ if m.opts.ApplyCover != nil {
+ if err := m.opts.ApplyCover(&m.pl, picked); err != nil {
+ m.status = err.Error()
+ return m, nil
+ }
+ }
+ m.cover, m.hasCover = picked, true
+ }
+ m.screen, m.step, m.scroll = ScreenReview, StepConfirm, 0
+ return m, m.clearPlacedCover()
+ }
+ return m, nil
+}
+
+const replantInterval = 90 * time.Millisecond
+
+func replantTick() tea.Cmd {
+ return tea.Tick(replantInterval, func(time.Time) tea.Msg { return replantMsg{} })
+}
+
+func (m *Module) renderCoverPreview(width, height int) {
+ if m.coverCursor < 0 || m.coverCursor >= len(m.covers) {
+ return
+ }
+ m.prev.Render(m.covers[m.coverCursor].Path, width, height)
+}
+
+func (m *Module) clearPlacedCover() tea.Cmd {
+ if m.placed == nil {
+ return nil
+ }
+ cmd := clearImage(*m.placed)
+ m.placed = nil
+ return cmd
+}
+
+// resetAlbum drops the review state carried over from the previous album.
+//
+// Only screen, step and scroll used to be reset when a new album opened, so a
+// second album inherited the first one's chosen cover and its candidate list --
+// and coversFetched stayed true, meaning the new album never fetched its own
+// candidates at all and the picker offered up the previous album's art. Nothing
+// downstream would catch that: the cover is a valid image, so every verification
+// step passes and the album archives with the wrong sleeve.
+func (m *Module) resetAlbum() {
+ m.cover, m.hasCover = CoverOption{}, false
+ m.covers, m.coverCursor, m.coversFetched = nil, 0, false
+ m.placed = nil
+ m.namesPath, m.namesSession = "", nil
+ m.log, m.outcome = nil, Outcome{}
+ m.err, m.status = nil, ""
+ m.scroll, m.offset, m.contentRows = 0, 0, 0
+}
+
+func (m Module) quit() (tea.Model, tea.Cmd) {
+ if m.cancel != nil {
+ m.cancel()
+ }
+ return m, tea.Quit
+}
+
+func firstIssue(p plan.Plan) string {
+ for _, i := range p.Issues {
+ if i.Hard {
+ return i.Text
+ }
+ }
+ return "unknown issue"
+}
+
+func (m Module) visibleAlbums() []scan.Album {
+ if m.filter == "" {
+ return m.albums
+ }
+ q := strings.ToLower(m.filter)
+ var out []scan.Album
+ for _, a := range m.albums {
+ if strings.Contains(strings.ToLower(a.Name), q) {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+func (m Module) View() tea.View {
+ v := tea.NewView(m.render())
+ v.AltScreen = true
+ if m.mode() == modeTiny {
+ v.AltScreen = false
+ v.MouseMode = tea.MouseModeNone
+ }
+ return v
+}
+
+func (m Module) render() string {
+ if m.mode() == modeTiny {
+ return m.renderTiny()
+ }
+
+ header := m.renderHeader()
+ footer := m.renderFooter()
+ body := m.renderBody()
+
+ bodyH := m.body().H
+ body = m.applyScroll(body, m.width, bodyH)
+ body = clamp(fitHeight(body, bodyH), m.width, bodyH)
+
+ parts := []string{header}
+ if m.railShown() {
+ parts = append(parts, m.renderRail())
+ }
+ parts = append(parts, body, footer)
+ return lipgloss.JoinVertical(lipgloss.Left, parts...)
+}
+
+func (m Module) renderHeader() string {
+ left := m.st.AppName.Render("intake")
+ var crumbs []string
+ switch m.screen {
+ case ScreenBrowse:
+ crumbs = append(crumbs, m.st.CrumbDim.Render("staging"))
+ case ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone:
+ crumbs = append(crumbs, m.st.CrumbDim.Render("staging"))
+ if m.current.Name != "" {
+ crumbs = append(crumbs, m.st.CrumbDim.Render("›"))
+ crumbs = append(crumbs, m.st.Crumb.Render(m.current.Name))
+ }
+ if m.screen == ScreenReview {
+ crumbs = append(crumbs, m.st.CrumbDim.Render("›"))
+ crumbs = append(crumbs, m.st.Accent.Render(m.step.String()))
+ }
+ }
+ right := ""
+ if n := len(m.albums); n > 0 && m.screen == ScreenBrowse {
+ right = m.st.Status.Render(fmt.Sprintf("%d album%s", n, plural(n)))
+ }
+ mid := strings.Join(crumbs, " ")
+ if mid == "" {
+ mid = m.st.CrumbDim.Render("library intake")
+ }
+ line := left + m.st.Dim.Render(" │ ") + mid
+ if right != "" {
+ pad := m.width - displayWidth(line) - displayWidth(right) - 1
+ if pad < 1 {
+ return clamp(line, m.width, 1)
+ }
+ line += strings.Repeat(" ", pad) + right + " "
+ }
+ return clamp(line, m.width, 1)
+}
+
+func (m Module) railShown() bool {
+ return m.screen == ScreenReview && m.mode() >= modeCompact
+}
+
+func (m Module) renderRail() string {
+ cells := make([]string, 0, stepCount)
+ for s := Step(0); s < stepCount; s++ {
+ label := s.String()
+ switch {
+ case s < m.step:
+ cells = append(cells, m.st.Success.Render("✓ "+label))
+ case s == m.step:
+ cells = append(cells, m.st.Selected.Render(" "+label+" "))
+ default:
+ cells = append(cells, m.st.Dim.Render(" "+label))
+ }
+ }
+ return " " + clamp(strings.Join(cells, m.st.Dim.Render(" · ")), m.width-1, 1)
+}
+
+func (m Module) renderFooter() string {
+ var hints []string
+ switch m.screen {
+ case ScreenBrowse:
+ if m.filtering {
+ hints = []string{"enter", "apply", "esc", "cancel"}
+ } else {
+ hints = []string{"↑↓", "move", "/", "filter", "enter", "review", "r", "refresh", "q", "quit"}
+ }
+ case ScreenReview:
+ hints = []string{"↑↓", "scroll", "enter", "continue"}
+ if m.step == StepTracks || m.step == StepMetadata {
+ hints = append(hints, "e", "edit names")
+ }
+ hints = append(hints, "esc", "back", "q", "quit")
+ case ScreenCoverPicker:
+ hints = []string{"↑↓", "choose", "enter", "use this cover", "esc", "cancel"}
+ case ScreenWorking:
+ hints = []string{"q", "stop watching"}
+ case ScreenDone:
+ hints = []string{"enter", "file another", "q", "quit"}
+ }
+ parts := make([]string, 0, len(hints)/2)
+ for i := 0; i+1 < len(hints); i += 2 {
+ parts = append(parts, m.st.HintKey.Render(hints[i])+" "+m.st.HintDesc.Render(hints[i+1]))
+ }
+ left := " " + strings.Join(parts, m.st.Dim.Render(" · "))
+ status := ""
+ if m.err != nil {
+ status = m.st.Error.Render(m.err.Error())
+ } else if m.status != "" {
+ status = m.st.Status.Render(m.status)
+ }
+ line := left
+ if status != "" {
+ gap := m.width - displayWidth(left) - displayWidth(status) - 2
+ if gap < 1 {
+ return clamp(left, m.width, 1)
+ }
+ line = left + strings.Repeat(" ", gap) + status + " "
+ }
+ return clamp(line, m.width, 1)
+}
+
+func (m Module) applyScroll(content string, width, height int) string {
+ lines := strings.Split(content, "\n")
+ for i, l := range lines {
+ lines[i] = truncateTo(l, width)
+ }
+ m.contentRows = len(lines)
+ if len(lines) <= height {
+ return strings.Join(lines, "\n")
+ }
+ off := m.scroll
+ maxOff := len(lines) - height
+ if off > maxOff {
+ off = maxOff
+ }
+ if off < 0 {
+ off = 0
+ }
+ window := lines[off : off+height]
+
+ if height >= 3 {
+ if off > 0 {
+ window[0] = m.st.Dim.Render(" ↑ more")
+ }
+ if off+height < len(lines) {
+ window[len(window)-1] = m.st.Dim.Render(fmt.Sprintf(" ↓ more (%d%%)", (off+height)*100/len(lines)))
+ }
+ }
+ return strings.Join(window, "\n")
+}
+
+func (m Module) coverPreviewWidth() int {
+ area := m.body()
+ listW := m.width / 3
+ if listW < 34 {
+ listW = 34
+ }
+ w := m.width - listW - 2
+ if w < 20 {
+ return 0
+ }
+ _ = area
+ return w
+}
+
+func (m Module) coverPreviewHeight() int {
+ area := m.body()
+ h := area.H - 1
+ if h < 5 {
+ h = 5
+ }
+ return h
+}
+
+func (m Module) renderBody() string {
+ switch m.screen {
+ case ScreenBrowse:
+ return m.viewBrowse()
+ case ScreenReview:
+ return m.viewReview()
+ case ScreenCoverPicker:
+ return m.viewCoverPicker()
+ case ScreenWorking:
+ return m.viewWorking()
+ case ScreenDone:
+ return m.viewDone()
+ }
+ return ""
+}
+
+func (m Module) renderTiny() string {
+ what := "staging"
+ switch m.screen {
+ case ScreenReview:
+ what = fmt.Sprintf("%s · %s", m.current.Name, m.step)
+ case ScreenWorking:
+ what = "working: " + m.current.Name
+ case ScreenDone:
+ what = "done: " + m.current.Name
+ case ScreenCoverPicker:
+ what = "choose cover"
+ }
+ return clamp(" intake · "+what, m.width, 1)
+}
+
+func (m Module) viewBrowse() string {
+ if !m.ready {
+ return m.st.Muted.Render(" " + m.spin.View() + " reading staging ...")
+ }
+ if m.err != nil {
+ return m.st.Error.Render(" " + m.err.Error())
+ }
+ if len(m.albums) == 0 {
+ return m.emptyStaging()
+ }
+
+ visible := m.visibleAlbums()
+ if len(visible) == 0 {
+ return m.st.Muted.Render(" no album matches " + fmt.Sprintf("%q", m.filter))
+ }
+
+ area := m.body()
+ rows := area.H - 1
+ if rows < 1 {
+ rows = 1
+ }
+ perItem := 2
+ if m.mode() == modeMini {
+ perItem = 1
+ }
+ capacity := rows / perItem
+ if capacity < 1 {
+ capacity = 1
+ }
+
+ if m.cursor < m.offset {
+ m.offset = m.cursor
+ }
+ if m.cursor >= m.offset+capacity {
+ m.offset = m.cursor - capacity + 1
+ }
+ end := m.offset + capacity
+ if end > len(visible) {
+ end = len(visible)
+ }
+
+ var b strings.Builder
+ head := "ALBUMS"
+ if m.filtering || m.filter != "" {
+ head = "FILTER " + m.filter
+ }
+ b.WriteString(" " + m.st.Heading.Render(head) + "\n")
+
+ for i := m.offset; i < end; i++ {
+ a := visible[i]
+ selected := i == m.cursor
+ name := a.Name
+ if selected {
+ b.WriteString(m.st.Marker.Render(" ▸ ") + m.st.Selected.Render(" "+truncateTo(name, m.width-8)+" ") + "\n")
+ } else {
+ b.WriteString(" " + m.st.Value.Render(truncateTo(name, m.width-6)) + "\n")
+ }
+ if perItem == 2 {
+ desc := m.albumSummary(a)
+ if selected {
+ b.WriteString(m.st.SelectedDim.Render(" "+truncateTo(desc, m.width-8)) + "\n")
+ } else {
+ b.WriteString(" " + m.st.Muted.Render(truncateTo(desc, m.width-6)) + "\n")
+ }
+ }
+ }
+ if end < len(visible) {
+ b.WriteString(m.st.Dim.Render(fmt.Sprintf(" … %d more", len(visible)-end)))
+ }
+ return b.String()
+}
+
+func (m Module) emptyStaging() string {
+ lines := []string{
+ "",
+ " " + m.st.Title.Render("Nothing is staged yet"),
+ "",
+ " " + m.st.Muted.Render("Put album folders in:"),
+ " " + m.st.Accent.Render(m.opts.StagingDir),
+ "",
+ " " + m.st.Muted.Render("intake expects one folder per album. Multi-disc"),
+ " " + m.st.Muted.Render("releases may keep their discs in subfolders."),
+ "",
+ " " + m.st.Muted.Render("Press r to look again."),
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (m Module) albumSummary(a scan.Album) string {
+ parts := []string{string(a.Status)}
+ switch {
+ case len(a.Tracks) > 0:
+ parts = append(parts, fmt.Sprintf("%d track%s", len(a.Tracks), plural(len(a.Tracks))))
+ case a.FileCount > 0:
+ parts = append(parts, fmt.Sprintf("%d file%s", a.FileCount, plural(a.FileCount)))
+ }
+ if note := strings.Join(a.Notes, "; "); note != "" {
+ parts = append(parts, note)
+ }
+ return strings.Join(parts, " · ")
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func plural(n int) string {
+ if n == 1 {
+ return ""
+ }
+ return "s"
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
@@ -0,0 +1,457 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+
+ "intake/internal/audio"
+ "intake/internal/config"
+ "intake/internal/plan"
+ "intake/internal/scan"
+ "intake/internal/tags"
+)
+
+func testModule(w, h int) Module {
+ m := New(Options{Config: config.Defaults(), StagingDir: "/staging"})
+ m.st = StylesFor(makeTheme(false))
+ m.width, m.height = w, h
+ return m
+}
+
+func albums() []scan.Album {
+ return []scan.Album{
+ {Name: "aphex-twin", Dir: "/staging/aphex-twin", Status: scan.StatusReady, FileCount: 12},
+ {Name: "boards-of-canada", Dir: "/staging/boards", Status: scan.StatusReady, FileCount: 18},
+ {Name: "empty-dir", Dir: "/staging/empty", Status: scan.StatusEmpty, Notes: []string{"no audio files found"}},
+ }
+}
+
+func key(s string) tea.KeyPressMsg {
+ switch s {
+ case "enter":
+ return tea.KeyPressMsg{Code: tea.KeyEnter}
+ case "esc":
+ return tea.KeyPressMsg{Code: tea.KeyEscape}
+ case "up":
+ return tea.KeyPressMsg{Code: tea.KeyUp}
+ case "down":
+ return tea.KeyPressMsg{Code: tea.KeyDown}
+ case "ctrl+c":
+ return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
+ case "/":
+ return tea.KeyPressMsg{Code: '/', Text: "/"}
+ default:
+ return tea.KeyPressMsg{Code: []rune(s)[0], Text: s}
+ }
+}
+
+func readyPlan() plan.Plan {
+ return plan.Build(scan.Album{
+ Name: "boards",
+ Status: scan.StatusReady,
+ Tracks: []audio.Track{{Path: "/s/01 x.flac", Ext: ".flac", Tags: []tags.Note{
+ {Field: tags.FieldTitle, Value: "Roygbiv"},
+ {Field: tags.FieldAlbum, Value: "Music Has the Right to Children"},
+ {Field: tags.FieldAlbumArtist, Value: "Boards of Canada"},
+ {Field: tags.FieldArtist, Value: "Boards of Canada"},
+ {Field: tags.FieldTrackNumber, Value: "1"},
+ {Field: tags.FieldDiscNumber, Value: "1"},
+ {Field: tags.FieldDate, Value: "1998"},
+ }}},
+ }, "/archive", "/library", plan.DirKey{})
+}
+
+func TestBrowseListsSortedAlbums(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ got := next.(Module)
+ if len(got.albums) != 3 {
+ t.Fatalf("got %d albums, want 3", len(got.albums))
+ }
+ if got.albums[0].Name != "aphex-twin" {
+ t.Errorf("albums should be sorted, got %q first", got.albums[0].Name)
+ }
+ if !strings.Contains(got.render(), "aphex-twin") {
+ t.Error("the list should render album names")
+ }
+}
+
+func TestBrowseFilter(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+
+ next, _ = m.Update(key("/"))
+ m = next.(Module)
+ if !m.filtering {
+ t.Fatal("/ should start filtering")
+ }
+ for _, r := range "board" {
+ next, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
+ m = next.(Module)
+ }
+ if vis := m.visibleAlbums(); len(vis) != 1 || vis[0].Name != "boards-of-canada" {
+ t.Errorf("filter should match one album, got %v", vis)
+ }
+ next, _ = m.Update(key("esc"))
+ m = next.(Module)
+ if m.filtering || m.filter != "" {
+ t.Error("esc should clear the filter")
+ }
+}
+
+func TestEnterOpensReviewInsteadOfFiling(t *testing.T) {
+ ran := false
+ m := testModule(100, 30)
+ m.opts.Run = func(ctx context.Context, _ scan.Album, _ func(string)) (Outcome, error) {
+ ran = true
+ return Outcome{}, nil
+ }
+
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ next, cmd := m.Update(key("enter"))
+ m = next.(Module)
+
+ if cmd == nil {
+ t.Fatal("enter should request the plan for the selected album")
+ }
+ if m.screen != ScreenBrowse {
+ t.Errorf("enter should not change screen until the plan arrives, got %v", m.screen)
+ }
+ if ran {
+ t.Error("enter must never file an album directly")
+ }
+}
+
+func TestPlanArrivalOpensMetadataStep(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ if m.screen != ScreenReview {
+ t.Fatalf("screen = %v, want review", m.screen)
+ }
+ if m.step != StepMetadata {
+ t.Errorf("step = %v, want metadata", m.step)
+ }
+}
+
+func TestReviewStepsAdvance(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ if m.step != StepTracks {
+ t.Fatalf("step = %v, want tracks", m.step)
+ }
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ if m.step != StepCover {
+ t.Fatalf("step = %v, want cover", m.step)
+ }
+ next, _ = m.Update(key("esc"))
+ m = next.(Module)
+ if m.step != StepTracks {
+ t.Errorf("esc should go back a step, got %v", m.step)
+ }
+}
+
+func TestBlockedPlanCannotLeaveMetadata(t *testing.T) {
+ m := testModule(100, 30)
+ blocked := plan.Build(scan.Album{
+ Name: "untagged",
+ Status: scan.StatusReady,
+ Tracks: []audio.Track{{Path: "/s/01.flac", Ext: ".flac", Tags: []tags.Note{
+ {Field: tags.FieldTitle, Value: "Song"},
+ }}},
+ }, "/archive", "/library", plan.DirKey{})
+ if !blocked.Blocked() {
+ t.Fatal("fixture should be blocked")
+ }
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "untagged"}, plan: blocked})
+ m = next.(Module)
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ if m.step != StepMetadata {
+ t.Error("a blocked plan must not advance")
+ }
+ if m.status == "" {
+ t.Error("the refusal should be explained")
+ }
+}
+
+func TestReviewShowsTheImportantFacts(t *testing.T) {
+ m := testModule(120, 40)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ view := m.render()
+ for _, want := range []string{"Metadata", "Boards of Canada", "Music Has the Right to Children", "1998"} {
+ if !strings.Contains(view, want) {
+ t.Errorf("metadata step should show %q, got:\n%s", want, view)
+ }
+ }
+
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ view = m.render()
+ if !strings.Contains(view, "roygbiv") {
+ t.Errorf("track step should show the target filename, got:\n%s", view)
+ }
+ if !strings.Contains(view, "1-01") {
+ t.Error("track step should show the disc-track prefix")
+ }
+}
+
+func TestReviewScrollsWhenContentOverflows(t *testing.T) {
+ m := testModule(80, 12)
+ p := readyPlan()
+ for i := 0; i < 40; i++ {
+ p.Tracks = append(p.Tracks, p.Tracks[0])
+ }
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: p})
+ m = next.(Module)
+
+ top := m.render()
+ if !strings.Contains(top, "more") {
+ t.Errorf("overflowing content should advertise more, got:\n%s", top)
+ }
+ if m.scroll != 0 {
+ t.Fatalf("scroll should start at 0, got %d", m.scroll)
+ }
+ next, _ = m.Update(key("down"))
+ m = next.(Module)
+ if m.scroll != 1 {
+ t.Fatalf("down should scroll, got %d", m.scroll)
+ }
+ scrolled := m.render()
+ if scrolled == top {
+ t.Error("scrolling should change what is drawn")
+ }
+ next, _ = m.Update(key("up"))
+ m = next.(Module)
+ if m.scroll != 0 {
+ t.Errorf("up should scroll back, got %d", m.scroll)
+ }
+}
+
+func TestLongContentScreensScroll(t *testing.T) {
+ for _, screen := range []Screen{ScreenReview, ScreenDone, ScreenWorking} {
+ m := testModule(80, 12)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ m.screen = screen
+ m.log = []string{"line one", "line two"}
+ next, _ = m.Update(key("down"))
+ got := next.(Module)
+ if got.scroll != 1 {
+ t.Errorf("screen %v did not scroll (scroll=%d)", screen, got.scroll)
+ }
+ }
+}
+
+func TestCoverPickerWindowsLongCandidateLists(t *testing.T) {
+ m := testModule(80, 14)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+
+ var many []CoverOption
+ for i := 0; i < 12; i++ {
+ many = append(many, CoverOption{Source: fmt.Sprintf("source-%02d", i), Width: 600, Height: 600})
+ }
+ next, _ = m.Update(coversMsg{covers: many})
+ m = next.(Module)
+
+ for i := 0; i < len(many)-1; i++ {
+ next, _ = m.Update(key("down"))
+ m = next.(Module)
+ }
+ if m.coverCursor != len(many)-1 {
+ t.Fatalf("cursor = %d, want %d", m.coverCursor, len(many)-1)
+ }
+ out := m.render()
+ if !strings.Contains(out, "source-11") {
+ t.Errorf("the window should follow the cursor to the last candidate, got:\n%s", out)
+ }
+ if strings.Contains(out, "source-00") {
+ t.Errorf("the first candidate should have scrolled out of view, got:\n%s", out)
+ }
+ for _, line := range strings.Split(out, "\n") {
+ if displayWidth(line) > 80 {
+ t.Errorf("line exceeds the terminal: %q", line)
+ }
+ }
+}
+
+func TestCoverPickerListsAndSelects(t *testing.T) {
+ covers := []CoverOption{
+ {Source: "embedded", Width: 300, Height: 300, Bytes: 1024, Label: "embedded"},
+ {Source: "coverartarchive", Width: 1200, Height: 1200, Bytes: 400000, Label: "CAA"},
+ }
+ m := testModule(120, 40)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ next, _ = m.Update(coversMsg{covers: covers})
+ m = next.(Module)
+ if m.screen != ScreenCoverPicker {
+ t.Fatalf("screen = %v, want cover picker", m.screen)
+ }
+ view := m.render()
+ if !strings.Contains(view, "embedded") || !strings.Contains(view, "coverartarchive") {
+ t.Errorf("both candidates should be listed, got:\n%s", view)
+ }
+ if !strings.Contains(view, "300x300") || !strings.Contains(view, "1200x1200") {
+ t.Error("dimensions must be shown, since a preview may not be available")
+ }
+
+ next, _ = m.Update(key("down"))
+ m = next.(Module)
+ if m.coverCursor != 1 {
+ t.Fatalf("cursor = %d, want 1", m.coverCursor)
+ }
+ applied := false
+ m.opts.ApplyCover = func(_ *plan.Plan, c CoverOption) error {
+ applied = true
+ if c.Source != "coverartarchive" {
+ t.Errorf("wrong cover applied: %s", c.Source)
+ }
+ return nil
+ }
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ if !applied {
+ t.Error("enter should apply the highlighted cover")
+ }
+ if !m.hasCover {
+ t.Error("the chosen cover should be remembered")
+ }
+ if m.screen != ScreenReview || m.step != StepConfirm {
+ t.Errorf("picking a cover should land on confirm, got screen=%v step=%v", m.screen, m.step)
+ }
+}
+
+func TestLayoutNeverExceedsTheTerminal(t *testing.T) {
+ sizes := []struct{ w, h int }{{40, 10}, {60, 18}, {80, 24}, {100, 30}, {200, 60}, {20, 6}, {12, 4}}
+ for _, sz := range sizes {
+ m := testModule(sz.w, sz.h)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ out := m.render()
+ for _, line := range strings.Split(out, "\n") {
+ if w := displayWidth(line); w > sz.w {
+ t.Errorf("%dx%d: line is %d cells wide: %q", sz.w, sz.h, w, line)
+ }
+ }
+ }
+}
+
+func TestTinyTerminalDegradesToOneLine(t *testing.T) {
+ m := testModule(80, 5)
+ if m.mode() != modeTiny {
+ t.Fatalf("a 5-row terminal should be tiny mode, got %v", m.mode())
+ }
+ out := m.render()
+ if strings.Count(out, "\n") != 0 {
+ t.Errorf("tiny mode should be one line, got:\n%s", out)
+ }
+ if !strings.Contains(out, "staging") {
+ t.Errorf("tiny mode should still say what is happening, got %q", out)
+ }
+}
+
+func TestModesSwitchOnSize(t *testing.T) {
+ cases := []struct {
+ w, h int
+ want mode
+ }{
+ {200, 60, modeFull},
+ {100, 30, modeFull},
+ {80, 24, modeCompact},
+ {70, 20, modeCompact},
+ {50, 16, modeMini},
+ {30, 10, modeMini},
+ {60, 6, modeTiny},
+ }
+ for _, c := range cases {
+ if got := testModule(c.w, c.h).mode(); got != c.want {
+ t.Errorf("%dx%d: mode = %v, want %v", c.w, c.h, got, c.want)
+ }
+ }
+}
+
+func TestBothThemesRenderEveryScreen(t *testing.T) {
+ for _, dark := range []bool{true, false} {
+ for _, screen := range []Screen{ScreenBrowse, ScreenReview, ScreenCoverPicker, ScreenDone, ScreenWorking} {
+ m := testModule(100, 30)
+ m.st = StylesFor(makeTheme(dark))
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ next, _ = m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ m.screen = screen
+ if out := m.render(); out == "" {
+ t.Errorf("dark=%v screen=%v rendered nothing", dark, screen)
+ }
+ }
+ }
+}
+
+func TestQuitKeys(t *testing.T) {
+ for _, k := range []string{"q", "ctrl+c"} {
+ m := testModule(100, 30)
+ _, cmd := m.Update(key(k))
+ if cmd == nil {
+ t.Errorf("%q should quit", k)
+ }
+ }
+}
+
+func TestErrorIsShownNotCrashed(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(albumsMsg{err: errBoom{}})
+ got := next.(Module)
+ if got.err == nil {
+ t.Fatal("error should be recorded")
+ }
+ if !strings.Contains(got.render(), "boom") {
+ t.Errorf("error should be visible, got:\n%s", got.render())
+ }
+}
+
+func TestDoneScreenExplainsPlaceholderRecovery(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ next, _ = m.Update(doneMsg{outcome: Outcome{
+ ArchiveDir: "/archive/a/b", LibraryDir: "/library/a/b",
+ Files: 10, Bytes: 1024 * 1024, Placeholder: true, Library: true,
+ }})
+ m = next.(Module)
+ view := m.render()
+ if !strings.Contains(view, "placeholder") {
+ t.Error("a placeholder cover should be called out")
+ }
+ if !strings.Contains(view, "re-cover") {
+ t.Error("the summary should say how to replace placeholder art")
+ }
+ if !strings.Contains(view, "1.0 MB") {
+ t.Errorf("the summary should show the size, got:\n%s", view)
+ }
+}
+
+type errBoom struct{}
+
+func (errBoom) Error() string { return "boom" }
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -0,0 +1,404 @@
+package tui
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "intake/internal/tags"
+)
+
+func (m Module) field(label, value string, width int) string {
+ inner := width - 4
+ if inner < 20 {
+ inner = 20
+ }
+ const labelW = 14
+ key := m.st.Label.Render(fmt.Sprintf(" %-*s ", labelW, label))
+ room := inner - labelW - 2
+ if room < 4 {
+ room = 4
+ }
+ line := key + m.st.Value.Render(truncateTo(value, room))
+ if displayWidth(line) > inner {
+ line = truncateTo(line, inner)
+ }
+ return line
+}
+
+func (m Module) stepHeader(title, blurb string) string {
+ return " " + m.st.Title.Render(title) + "\n" +
+ " " + m.st.Muted.Render(truncateTo(blurb, m.width-4)) + "\n"
+}
+
+func (m Module) viewReview() string {
+ switch m.step {
+ case StepMetadata:
+ return m.stepMetadata()
+ case StepTracks:
+ return m.stepTracks()
+ case StepCover:
+ return m.stepCover()
+ case StepConfirm:
+ return m.stepConfirm()
+ }
+ return ""
+}
+
+func (m Module) stepMetadata() string {
+ var b strings.Builder
+ b.WriteString(m.stepHeader("Metadata", "what intake read from the tags, and what it will write"))
+ b.WriteString("\n")
+
+ meta := strings.Join([]string{
+ m.field("album artist", orDash(m.pl.AlbumArt), m.width),
+ m.field("album", orDash(m.pl.AlbumName), m.width),
+ m.field("year", orDash(m.pl.Year), m.width),
+ m.field("tracks", fmt.Sprintf("%d", len(m.pl.Tracks)), m.width),
+ }, "\n")
+ b.WriteString(panelWith(m.st, "", meta, m.width, true))
+ b.WriteString("\n\n")
+
+ if m.pl.VariousArt {
+ b.WriteString(" " + m.st.Warning.Render("compilation") + " " +
+ m.st.Muted.Render("per-track artists differ, so this files under various-artists/") + "\n\n")
+ }
+
+ dest := strings.Join([]string{
+ m.field("archive", orDash(m.pl.ArchiveDir), m.width),
+ m.field("library", orDash(m.pl.LibraryDir), m.width),
+ }, "\n")
+ b.WriteString(panelWith(m.st, "", dest, m.width, false))
+ b.WriteString("\n\n")
+
+ var tagLines []string
+ if len(m.pl.Dropped) > 0 {
+ tagLines = append(tagLines, m.st.Label.Render(" removing ")+
+ m.st.Warning.Render(truncateTo(strings.Join(m.pl.Dropped, ", "), m.width-16)))
+ } else {
+ tagLines = append(tagLines, m.st.Label.Render(" removing ")+
+ m.st.Muted.Render("nothing, no extra tags present"))
+ }
+ tagLines = append(tagLines, m.st.Label.Render(" keeping ")+
+ m.st.Muted.Render(truncateTo(strings.Join(tags.Keep, ", "), m.width-16)))
+ b.WriteString(strings.Join(tagLines, "\n"))
+ b.WriteString("\n")
+
+ b.WriteString(m.issueBlock())
+ return b.String()
+}
+
+func (m Module) stepTracks() string {
+ var b strings.Builder
+ b.WriteString(m.stepHeader("Track names", "the filenames intake will write into the archive and library"))
+ b.WriteString("\n")
+ const numW = 6
+ titleW := m.width / 4
+ if titleW < 12 {
+ titleW = 12
+ }
+ if titleW > 34 {
+ titleW = 34
+ }
+ fileW := m.width - numW - titleW - 6
+ if fileW < 10 {
+ fileW = 10
+ }
+ head := fmt.Sprintf(" %-*s %-*s %s", numW, "#", titleW, "title", "filename")
+ b.WriteString(m.st.Label.Render(truncateTo(head, m.width)) + "\n")
+ b.WriteString(m.st.Rule.Render(" "+strings.Repeat("─", maxInt(1, m.width-2))) + "\n")
+
+ width := 2
+ if m.mode() >= modeCompact {
+ width = 3
+ }
+ for _, t := range m.pl.Tracks {
+ title := t.Title
+ if title == "" {
+ title = "(no title)"
+ }
+ num := fmt.Sprintf("%d-%02d", t.Disc, t.Number)
+ if len(m.pl.Tracks) >= 100 {
+ num = fmt.Sprintf("%d-%03d", t.Disc, t.Number)
+ }
+ line := fmt.Sprintf(" %s %s %s",
+ m.st.Muted.Render(fmt.Sprintf("%-*s", numW, num)),
+ m.st.Value.Render(fmt.Sprintf("%-*s", titleW, truncateTo(title, titleW))),
+ m.st.Accent.Render(truncateTo(t.Target, fileW+width)),
+ )
+ b.WriteString(truncateTo(line, m.width) + "\n")
+ }
+ if len(m.pl.Tracks) == 0 {
+ b.WriteString(" " + m.st.Muted.Render("no tracks") + "\n")
+ }
+ b.WriteString("\n")
+ b.WriteString(m.issueBlock())
+ return b.String()
+}
+
+func (m Module) stepCover() string {
+ var b strings.Builder
+ b.WriteString(m.stepHeader("Cover art", "pick an image, or accept the placeholder and replace it later"))
+ b.WriteString("\n")
+
+ if m.hasCover {
+ b.WriteString(panelWith(m.st, "", strings.Join([]string{
+ m.field("source", m.cover.Source, m.width),
+ m.field("image", m.cover.Label, m.width),
+ m.field("size", fmt.Sprintf("%dx%d, %s", m.cover.Width, m.cover.Height, humanBytes(m.cover.Bytes)), m.width),
+ }, "\n"), m.width, true))
+ b.WriteString("\n\n" + " " + m.st.Success.Render("cover selected") + "\n")
+ } else {
+ b.WriteString(panelWith(m.st, "", strings.Join([]string{
+ m.field("cover", "not chosen yet", m.width),
+ }, "\n"), m.width, true))
+ if !m.coversFetched {
+ b.WriteString("\n\n " + m.st.Muted.Render("Press enter to look for cover art.") + "\n")
+ } else {
+ b.WriteString("\n\n " + m.st.Muted.Render("Press enter to choose from the candidates found.") + "\n")
+ }
+ }
+ b.WriteString("\n" + " " + m.st.Muted.Render("Press enter to continue.") + "\n")
+ return b.String()
+}
+
+func (m Module) stepConfirm() string {
+ var b strings.Builder
+ b.WriteString(m.stepHeader("Confirm", "this is the point of no return for the staging folder"))
+ b.WriteString("\n")
+
+ cover := "none"
+ switch {
+ case m.hasCover && m.cover.Placeholder:
+ cover = "placeholder (recorded so you can replace it later)"
+ case m.hasCover:
+ cover = fmt.Sprintf("%s (%dx%d)", m.cover.Source, m.cover.Width, m.cover.Height)
+ }
+
+ rows := strings.Join([]string{
+ m.field("album", orDash(m.pl.AlbumName), m.width),
+ m.field("artist", orDash(m.pl.AlbumArt), m.width),
+ m.field("tracks", fmt.Sprintf("%d", len(m.pl.Tracks)), m.width),
+ m.field("cover", cover, m.width),
+ m.field("archive", orDash(m.pl.ArchiveDir), m.width),
+ m.field("library", orDash(m.pl.LibraryDir), m.width),
+ m.field("staging", "removed after the archive verifies", m.width),
+ }, "\n")
+ b.WriteString(panelWith(m.st, "", rows, m.width, true))
+ b.WriteString("\n\n")
+ b.WriteString(" " + m.st.Success.Render("Press enter to file this album.") + "\n")
+ b.WriteString(" " + m.st.Muted.Render("Press esc to go back.") + "\n")
+ return b.String()
+}
+
+func (m Module) issueBlock() string {
+ if len(m.pl.Warnings) == 0 && len(m.pl.Issues) == 0 {
+ return " " + m.st.Dim.Render("no warnings") + "\n"
+ }
+ var b strings.Builder
+ for _, i := range m.pl.Issues {
+ if i.Hard {
+ b.WriteString(" " + m.st.Danger.Render("✗ ") + m.st.Danger.Render(truncateTo(i.Text, m.width-6)) + "\n")
+ } else {
+ b.WriteString(" " + m.st.Warning.Render("! ") + truncateTo(i.Text, m.width-6) + "\n")
+ }
+ }
+ for _, w := range m.pl.Warnings {
+ b.WriteString(" " + m.st.Warning.Render("! ") + m.st.Muted.Render(truncateTo(w, m.width-6)) + "\n")
+ }
+ return b.String()
+}
+
+func (m Module) viewCoverPicker() string {
+ if len(m.covers) == 0 {
+ return " " + m.st.Muted.Render("no cover candidates found") + "\n" +
+ " " + m.st.Muted.Render("press esc to go back and continue without one")
+ }
+
+ area := m.body()
+ listW := m.width / 3
+ if listW < 34 {
+ listW = 34
+ }
+ previewW := m.width - listW - 2
+ if previewW < 20 {
+ return m.coverList(m.width-2, area.H, true)
+ }
+ previewH := area.H - 1
+ if previewH < 5 {
+ previewH = 5
+ }
+
+ left := m.coverList(listW, previewH, false)
+ right := m.coverPreview(previewW, previewH)
+
+ left = clamp(fitHeight(left, previewH), listW, previewH)
+ right = clamp(fitHeight(right, previewH), previewW, previewH)
+ return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
+}
+
+func (m Module) coverList(width, height int, withDetail bool) string {
+ per := 2
+ if withDetail {
+ per = 3
+ }
+ rows := height - 1
+ if rows < per {
+ rows = per
+ }
+ capacity := rows / per
+ if capacity < 1 {
+ capacity = 1
+ }
+ off := 0
+ if m.coverCursor >= capacity {
+ off = m.coverCursor - capacity + 1
+ }
+ end := off + capacity
+ if end > len(m.covers) {
+ end = len(m.covers)
+ }
+
+ var b strings.Builder
+ head := "COVERS"
+ if len(m.covers) > capacity {
+ head = fmt.Sprintf("COVERS %d/%d", m.coverCursor+1, len(m.covers))
+ }
+ b.WriteString(" " + m.st.Heading.Render(head) + "\n")
+
+ for i := off; i < end; i++ {
+ c := m.covers[i]
+ name := string(c.Source)
+ if i == m.coverCursor {
+ b.WriteString(m.st.Marker.Render("\u258e ") + m.st.Selected.Render(" "+truncateTo(name, width-7)+" ") + "\n")
+ } else {
+ b.WriteString(" " + m.st.Value.Render(truncateTo(name, width-5)) + "\n")
+ }
+ detail := fmt.Sprintf("%dx%d \u00b7 %s", c.Width, c.Height, humanBytes(c.Bytes))
+ if withDetail && c.Label != "" {
+ detail += " \u00b7 " + c.Label
+ }
+ b.WriteString(" " + m.st.Muted.Render(truncateTo(detail, width-6)) + "\n")
+ }
+ if end < len(m.covers) {
+ b.WriteString(m.st.Dim.Render(fmt.Sprintf(" \u2026 %d more", len(m.covers)-end)))
+ }
+ return b.String()
+}
+
+func (m *Module) placeCover() tea.Cmd {
+ place, ok := m.prev.TakePlacement()
+ if !ok {
+ if m.placed == nil {
+ return nil
+ }
+ return drawImage(*m.placed)
+ }
+ place.id, place.placement = baseImageID, basePlacementID
+ m.placed = &place
+ return drawImage(place)
+}
+
+func (m Module) coverPreview(width, height int) string {
+ c := m.covers[m.coverCursor]
+ var b strings.Builder
+ b.WriteString(" " + m.st.Heading.Render("PREVIEW") + " " +
+ m.st.Dim.Render("("+m.prev.Mode()+")") + "\n")
+
+ if !m.prev.Available() {
+ b.WriteString(" " + m.st.Muted.Render("no image renderer available") + "\n")
+ b.WriteString(" " + m.st.Dim.Render("install chafa, or use a graphics-capable terminal") + "\n")
+ } else {
+ lines := m.prev.Render(c.Path, width-2, height-6)
+ if len(lines) == 0 {
+ b.WriteString(" " + m.st.Muted.Render("preview unavailable for this image") + "\n")
+ }
+ for _, l := range lines {
+ // don't truncate these, cutting a row unbinds the cells past the cut
+ if strings.ContainsRune(l, placeholderRune) {
+ b.WriteString(" " + l + "\n")
+ continue
+ }
+ b.WriteString(" " + truncateTo(l, width-2) + "\n")
+ }
+ }
+ b.WriteString("\n")
+ b.WriteString(" " + m.st.Value.Render(truncateTo(c.Label, width-2)) + "\n")
+ b.WriteString(" " + m.st.Muted.Render(fmt.Sprintf("%dx%d · %s", c.Width, c.Height, humanBytes(c.Bytes))) + "\n")
+ if c.Path != "" {
+ b.WriteString(" " + m.st.Dim.Render(truncateTo(filepath.Base(c.Path), width-2)) + "\n")
+ }
+ return b.String()
+}
+
+func (m Module) viewWorking() string {
+ var b strings.Builder
+ b.WriteString(" " + m.st.Title.Render("Filing "+m.current.Name) + "\n\n")
+ b.WriteString(" " + m.spin.View() + " " + m.st.Muted.Render(m.status) + "\n\n")
+ for _, line := range m.log {
+ b.WriteString(" " + m.st.Muted.Render(truncateTo(line, m.width-4)) + "\n")
+ }
+ if len(m.log) == 0 {
+ b.WriteString(" " + m.st.Dim.Render("the pipeline reports as it goes") + "\n")
+ }
+ return b.String()
+}
+
+func (m Module) viewDone() string {
+ var b strings.Builder
+ b.WriteString(" " + m.st.Success.Render("✓ filed") + " " + m.st.Title.Render(m.current.Name) + "\n\n")
+
+ rows := []string{
+ m.field("archive", orDash(m.outcome.ArchiveDir), m.width),
+ }
+ if m.outcome.Library {
+ rows = append(rows, m.field("library", orDash(m.outcome.LibraryDir), m.width))
+ } else {
+ rows = append(rows, m.field("library", "not built", m.width))
+ }
+ rows = append(rows, m.field("files", fmt.Sprintf("%d (%s)", m.outcome.Files, humanBytes(m.outcome.Bytes)), m.width))
+ switch {
+ case m.outcome.Placeholder:
+ rows = append(rows, m.field("cover", "placeholder", m.width))
+ case m.outcome.Cover != "":
+ rows = append(rows, m.field("cover", m.outcome.Cover, m.width))
+ default:
+ rows = append(rows, m.field("cover", "none", m.width))
+ }
+ b.WriteString(panelWith(m.st, "", strings.Join(rows, "\n"), m.width, true))
+ b.WriteString("\n\n")
+
+ if m.outcome.Placeholder {
+ b.WriteString(" " + m.st.Warning.Render("This album has placeholder art.") + "\n")
+ b.WriteString(" " + m.st.Muted.Render("It will be listed by ") + m.st.Code.Render("intake library verify") + "\n")
+ b.WriteString(" " + m.st.Muted.Render("and can be replaced with ") + m.st.Code.Render("intake library re-cover") + "\n\n")
+ }
+ for _, w := range m.outcome.Warnings {
+ b.WriteString(" " + m.st.Warning.Render("! ") + m.st.Muted.Render(truncateTo(w, m.width-6)) + "\n")
+ }
+ b.WriteString(" " + m.st.Muted.Render("Press enter to pick the next album.") + "\n")
+ return b.String()
+}
+
+func orDash(v string) string {
+ if strings.TrimSpace(v) == "" {
+ return "—"
+ }
+ return v
+}
+
+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])
+}
diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go
@@ -0,0 +1,281 @@
+package tui
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "intake/internal/scan"
+)
+
+func TestFrameIsExactHeight(t *testing.T) {
+ for _, sz := range []struct{ w, h int }{{80, 24}, {100, 30}, {60, 20}, {200, 50}} {
+ m := testModule(sz.w, sz.h)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ lines := strings.Split(m.render(), "\n")
+ if len(lines) > sz.h {
+ t.Errorf("%dx%d: frame is %d rows, more than the terminal", sz.w, sz.h, len(lines))
+ }
+ }
+}
+
+func TestFooterIsOnItsOwnRow(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ lines := strings.Split(m.render(), "\n")
+ footer := stripANSI(lines[len(lines)-1])
+ if !strings.Contains(footer, "filter") {
+ t.Errorf("last row should be the key hints, got %q", footer)
+ }
+ if strings.Contains(footer, "quit14") || strings.Contains(footer, "quit3") {
+ t.Errorf("status is glued to the hints: %q", footer)
+ }
+}
+
+func TestHeaderHasASeparator(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ header := stripANSI(strings.Split(m.render(), "\n")[0])
+ if !strings.Contains(header, "intake") {
+ t.Fatalf("header should name the app, got %q", header)
+ }
+ if !strings.Contains(header, "│") && !strings.Contains(header, "staging") {
+ t.Errorf("header should separate the app name from the location, got %q", header)
+ }
+ if strings.Contains(header, "staging14") {
+ t.Errorf("status is glued to the breadcrumb: %q", header)
+ }
+}
+
+func TestAlbumRowsKeepIndentation(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(albumsMsg{albums: albums()})
+ m = next.(Module)
+ lines := strings.Split(m.render(), "\n")
+
+ var found int
+ for _, l := range lines {
+ plain := stripANSI(l)
+ if strings.Contains(plain, "boards-of-canada") {
+ found++
+ if !strings.HasPrefix(plain, " ") {
+ t.Errorf("album row lost its indentation: %q", plain)
+ }
+ }
+ }
+ if found == 0 {
+ t.Fatal("expected to find a non-selected album row")
+ }
+}
+
+func TestPanelBordersLineUp(t *testing.T) {
+ m := testModule(100, 30)
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()})
+ m = next.(Module)
+ for _, line := range strings.Split(m.render(), "\n") {
+ plain := stripANSI(line)
+ if strings.HasPrefix(plain, "╭") || strings.HasPrefix(plain, "│") || strings.HasPrefix(plain, "╰") {
+ if displayWidth(plain) > m.width {
+ t.Errorf("panel line wider than the terminal: %q", plain)
+ }
+ }
+ }
+}
+
+func TestFieldFitsInsideItsPanel(t *testing.T) {
+ for _, w := range []int{40, 60, 80, 100} {
+ m := testModule(w, 30)
+ line := stripANSI(m.field("album artist", strings.Repeat("x", 200), w))
+ if displayWidth(line) > w-4 {
+ t.Errorf("width %d: field is %d cells, wider than the panel inner width", w, displayWidth(line))
+ }
+ }
+}
+
+func TestCoverPickerEmitsACoherentGrid(t *testing.T) {
+ m, img := pickerModule(t)
+
+ panel := m.coverPreview(m.coverPreviewWidth(), m.coverPreviewHeight())
+ var gridLines []string
+ for _, l := range strings.Split(panel, "\n") {
+ if strings.ContainsRune(l, placeholderRune) {
+ gridLines = append(gridLines, l)
+ }
+ }
+ if len(gridLines) == 0 {
+ t.Fatalf("no placeholder cells in the preview; panel was:\n%s", panel)
+ }
+ want := strings.Count(gridLines[0], string(placeholderRune))
+ if want < 4 {
+ t.Fatalf("grid is only %d cells wide", want)
+ }
+ for i, l := range gridLines {
+ if got := strings.Count(l, string(placeholderRune)); got != want {
+ t.Errorf("grid row %d has %d cells, row 0 has %d", i, got, want)
+ }
+ if !strings.Contains(l, "\x1b[38:2:") {
+ t.Errorf("grid row %d lost the image id colour", i)
+ }
+ if !strings.HasPrefix(l, " ") {
+ t.Errorf("grid row %d is not indented like the rest of the panel", i)
+ }
+ if got := displayWidth(l); got != want+1 {
+ t.Errorf("grid row %d measures %d, want %d (cells plus indent)", i, got, want+1)
+ }
+ }
+ for _, l := range strings.Split(panel, "\n") {
+ if strings.Contains(l, "PREVIEW") {
+ if !strings.HasPrefix(l, " ") {
+ t.Errorf("heading is not indented but the grid is: %q", l)
+ }
+ break
+ }
+ }
+ if got := m.coverPreviewWidth(); got < want+1 {
+ t.Errorf("grid plus indent is %d cells wide but the column is %d", want+1, got)
+ }
+ _ = img
+}
+
+func TestCoverPickerNeverExceedsTheTerminal(t *testing.T) {
+ for _, w := range []int{60, 72, 80, 100, 120, 160, 200} {
+ for _, h := range []int{20, 24, 34, 50} {
+ m, _ := pickerModule(t)
+ m.width, m.height = w, h
+ for _, l := range strings.Split(m.viewCoverPicker(), "\n") {
+ if got := displayWidth(l); got > w {
+ t.Errorf("%dx%d: a line measures %d columns, wider than the terminal", w, h, got)
+ break
+ }
+ }
+ }
+ }
+}
+
+func pickerModule(t *testing.T) (Module, string) {
+ t.Helper()
+ dir := t.TempDir()
+ img := filepath.Join(dir, "cover.png")
+ writeSolidPNG(t, img, 300)
+ prev := &ImagePreviewer{mode: imageKitty}
+ m := Module{
+ st: StylesFor(makeTheme(true)),
+ prev: prev,
+ screen: ScreenCoverPicker,
+ covers: []CoverOption{{Path: img, Label: "local", Width: 300, Height: 300}},
+ width: 120,
+ height: 40,
+ }
+ return m, img
+}
+
+func writeSolidPNG(t *testing.T, path string, n int) {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ img.Set(x, y, color.RGBA{0x20, 0x40, 0x80, 0xff})
+ }
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Opening a second album must not inherit the first one's cover art. It used to:
+// the cover, the candidate list and the "already fetched" flag all survived, so
+// the next album skipped its own lookup and offered the previous album's art.
+func TestSecondAlbumInheritsNoCoverState(t *testing.T) {
+ m := testModule(100, 30)
+
+ next, _ := m.Update(planMsg{album: scan.Album{Name: "first"}, plan: readyPlan()})
+ m = next.(Module)
+ next, _ = m.Update(coversMsg{covers: []CoverOption{
+ {Path: "/covers/first.jpg", Label: "folder", Width: 2000, Height: 2000},
+ }})
+ m = next.(Module)
+ m.coverCursor = 0
+ next, _ = m.Update(key("enter"))
+ m = next.(Module)
+ if !m.hasCover {
+ t.Fatal("first album should have a cover selected")
+ }
+
+ next, _ = m.Update(planMsg{album: scan.Album{Name: "second"}, plan: readyPlan()})
+ m = next.(Module)
+
+ if m.hasCover {
+ t.Errorf("second album inherited a cover: %+v", m.cover)
+ }
+ if len(m.covers) != 0 {
+ t.Errorf("second album inherited %d candidate(s)", len(m.covers))
+ }
+ if m.coversFetched {
+ t.Error("second album thinks its covers were already fetched, so it will never look")
+ }
+ if m.coverCursor != 0 {
+ t.Errorf("coverCursor = %d, want 0", m.coverCursor)
+ }
+ if m.placed != nil {
+ t.Error("second album inherited a placed image")
+ }
+}
+
+// An album is minutes of copying and encoding. The log used to render from the
+// top, so the lines that mattered -- the ones that just arrived -- were the ones
+// pushed off the bottom.
+func TestWorkingScreenFollowsTheNewestLine(t *testing.T) {
+ m := testModule(80, 12)
+ m.screen = ScreenWorking
+ m.current = scan.Album{Name: "an-album"}
+ m.follow = true
+
+ for i := 0; i < 100; i++ {
+ next, _ := m.Update(progressMsg(fmt.Sprintf("line %d", i)))
+ m = next.(Module)
+ }
+
+ body := m.render()
+ if !strings.Contains(body, "line 99") {
+ t.Error("the newest line is not on screen")
+ }
+ if strings.Contains(body, "line 0\n") {
+ t.Error("the oldest line is still on screen; the view is not following")
+ }
+}
+
+// Scrolling up has to stop the follow or the next line yanks the view back down,
+// and scrolling down again resumes it.
+func TestWorkingScreenScrollingStopsFollowing(t *testing.T) {
+ m := testModule(80, 12)
+ m.screen = ScreenWorking
+ m.current = scan.Album{Name: "an-album"}
+ m.follow = true
+ next, _ := m.Update(progressMsg("line 0"))
+ m = next.(Module)
+
+ next, _ = m.Update(key("up"))
+ m = next.(Module)
+ if m.follow {
+ t.Error("scrolling up should stop the follow")
+ }
+
+ next, _ = m.Update(key("down"))
+ m = next.(Module)
+ if !m.follow {
+ t.Error("scrolling down should resume the follow")
+ }
+}
diff --git a/scripts/gen-diacritics.py b/scripts/gen-diacritics.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""Regenerate internal/tui/diacritics.go from kitty's rowcolumn-diacritics table.
+
+Usage: scripts/gen-diacritics.py [table.txt]
+"""
+import sys
+import urllib.request
+
+URL = ("https://raw.githubusercontent.com/kovidgoyal/kitty/master/"
+ "gen/rowcolumn-diacritics.txt")
+
+OUT = "internal/tui/diacritics.go"
+
+
+def load(path):
+ if path:
+ return open(path).read()
+ return urllib.request.urlopen(URL, timeout=30).read().decode()
+
+
+def main():
+ text = load(sys.argv[1] if len(sys.argv) > 1 else None)
+ marks = [int(l.split(";")[0], 16) for l in text.splitlines()
+ if l.strip() and not l.startswith("#")]
+ if len(marks) != 297:
+ sys.exit(f"expected 297 marks, read {len(marks)}")
+
+ lines = [
+ "// Code generated by scripts/gen-diacritics.py; DO NOT EDIT.",
+ "",
+ "package tui",
+ "",
+ "// charmbracelet/x/ansi/kitty only ships the first six of these and returns",
+ "// the first mark for anything past five, which quietly mis-addresses every",
+ "// cell past column six. this is kitty's full table.",
+ "var placeholderDiacritics = [...]rune{",
+ ]
+ for i in range(0, len(marks), 8):
+ chunk = marks[i:i + 8]
+ lines.append("\t" + " ".join(f"0x{m:04X}," for m in chunk))
+ lines += [
+ "}",
+ "",
+ "func diacriticAt(i int) rune {",
+ "\tif i < 0 || i >= len(placeholderDiacritics) {",
+ "\t\treturn placeholderDiacritics[0]",
+ "\t}",
+ "\treturn placeholderDiacritics[i]",
+ "}",
+ "",
+ ]
+ with open(OUT, "w") as f:
+ f.write("\n".join(lines))
+ print(f"wrote {OUT}: {len(marks)} marks")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/grid-probe.py b/scripts/grid-probe.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Run the TUI as a kitty terminal and report what the cover grid emitted.
+
+Usage: scripts/grid-probe.py [--root DIR] [--width N] [--height N]
+"""
+import argparse, fcntl, os, pty, re, select, struct, sys, termios, time
+
+APC = b"\x1b_G"
+
+class Session:
+ def __init__(self, args):
+ self.raw = b""
+ env = dict(os.environ)
+ env["TERM"] = "xterm-kitty"
+ env["COLORTERM"] = "truecolor"
+ env["LINES"] = str(args.height)
+ env["COLUMNS"] = str(args.width)
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.environ.update(env)
+ os.execv(args.binary, [args.binary, "tui", "--root", args.root])
+ self.fd = fd
+ fcntl.ioctl(fd, termios.TIOCSWINSZ,
+ struct.pack("HHHH", args.height, args.width, 0, 0))
+ self.pid = pid
+
+ def drain(self, seconds):
+ end = time.time() + seconds
+ out = b""
+ while time.time() < end:
+ r, _, _ = select.select([self.fd], [], [], 0.1)
+ if not r:
+ continue
+ try:
+ chunk = os.read(self.fd, 1 << 20)
+ except OSError:
+ break
+ if not chunk:
+ break
+ out += chunk
+ self.raw += out
+ return out
+
+ def send(self, keys):
+ os.write(self.fd, keys)
+ return self.drain(0.8)
+
+ def close(self):
+ try:
+ os.close(self.fd)
+ except OSError:
+ pass
+ try:
+ os.kill(self.pid, 9)
+ except ProcessLookupError:
+ pass
+ os.waitpid(self.pid, 0)
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--root", default="/tmp/intake-tour")
+ ap.add_argument("--binary", default="./out/intake")
+ ap.add_argument("--width", type=int, default=104)
+ ap.add_argument("--height", type=int, default=34)
+ ap.add_argument("--save", default="", help="write the raw output here")
+ args = ap.parse_args()
+
+ s = Session(args)
+ try:
+ s.drain(3.0)
+ # browse -> metadata -> track names -> cover art -> search for candidates
+ for _ in range(4):
+ os.write(s.fd, b"\r")
+ s.drain(2.0)
+ s.drain(3.0)
+ finally:
+ s.close()
+
+ data = s.raw
+ if args.save:
+ with open(args.save, "wb") as f:
+ f.write(data)
+ print(f"raw output saved to {args.save}")
+ print(f"captured {len(data)} bytes")
+ print(f"graphics commands: {data.count(APC)}")
+ cells = data.count("\U0010EEEE".encode())
+ print(f"placeholder cells: {cells}")
+ for label, pat in [
+ ("38:2:...m (colon truecolor)", rb"\x1b\[38:2:[0-9]+:[0-9]+:[0-9]+m"),
+ ("38;2;...m (semicolon truecolor)", rb"\x1b\[38;2;[0-9]+;[0-9]+;[0-9]+m"),
+ ("38;5;...m (256 colour)", rb"\x1b\[38;5;[0-9]+m"),
+ ("38:5:...m", rb"\x1b\[38:5:[0-9]+m"),
+ ]:
+ found = set(re.findall(pat, data))
+ print(f"{label}: {len(found)} distinct")
+ for c in list(found)[:6]:
+ print(f" {c.decode()}")
+ # A grid row is a run of cells; report how wide the widest run is.
+ widest = 0
+ for line in data.split(b"\n"):
+ n = line.count("\U0010EEEE".encode())
+ widest = max(widest, n)
+ print(f"widest grid row: {widest} cells")
+ if cells == 0:
+ print("\nNO PLACEHOLDER CELLS REACHED THE TERMINAL")
+ return 1
+ print("\nthe grid reached the terminal")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/make-fixtures.sh b/scripts/make-fixtures.sh
@@ -0,0 +1,445 @@
+#!/usr/bin/env bash
+# Generate synthetic albums of tiny tones for testing. No real music.
+#
+# Usage: scripts/make-fixtures.sh [STAGING_DIR] (default ~/music/staging)
+
+set -euo pipefail
+
+STAGING="${1:-$HOME/music/staging}"
+
+POOL="$(mktemp -d)"
+trap 'rm -rf "$POOL"' EXIT
+
+command -v ffmpeg >/dev/null || { echo "need ffmpeg" >&2; exit 1; }
+command -v metaflac >/dev/null || { echo "need metaflac" >&2; exit 1; }
+
+echo "==> staging dir: $STAGING"
+mkdir -p "$STAGING"
+
+# ---------------------------------------------------------------------------
+# helpers
+# ---------------------------------------------------------------------------
+
+# base <name> <seconds> <freq> -- one lossless tone in the reusable pool
+base() {
+ local name="$1" dur="$2" freq="$3"
+ ffmpeg -hide_banner -loglevel error -y \
+ -f lavfi -i "sine=frequency=${freq}:duration=${dur}:sample_rate=44100" \
+ -af "volume=0.2,afade=t=in:d=0.05,afade=t=out:st=$(awk "BEGIN{print $dur-0.1}"):d=0.1" \
+ -c:a flac -compression_level 5 "$POOL/$name.flac"
+}
+
+# make <album-dir> <spec...>
+# spec forms:
+# t:<title> title (required, may contain spaces/odd chars)
+# any:key=value a tag; may be repeated
+# Disc/track numbers fall back to position within the current disc.
+make() {
+ local dir="$STAGING/$1"; shift
+ mkdir -p "$dir"
+
+ local disc=1 track=0 i=0 sz
+ local -a titles=() tags=()
+
+ # first pass: split specs
+ local spec
+ for spec in "$@"; do
+ case "$spec" in
+ t:*) titles+=("${spec#t:}") ;;
+ any:*) tags+=("${spec#any:}") ;;
+ *) echo "bad spec: $spec" >&2; return 1 ;;
+ esac
+ done
+
+ for spec in "${titles[@]}"; do
+ track=$((track+1))
+ i=$((i+1))
+
+ # cycle the tone pool so files are distinct but generation is cheap
+ case $((i % 3)) in
+ 0) sz=pool_a ;; 1) sz=pool_b ;; *) sz=pool_c ;;
+ esac
+ cp "$POOL/$sz.flac" "$dir/tmp_$i.flac"
+
+ local -a alltags=()
+ # per-track tags supplied via any: apply to every track; callers that need
+ # per-track differences use the special any:ARTIST@N= form handled below.
+ local t
+ for t in "${tags[@]}"; do
+ case "$t" in
+ *"@"[0-9]*=*) ;; # per-track, handled after
+ *) alltags+=("$t") ;;
+ esac
+ done
+ alltags+=("TITLE=$spec")
+
+ # per-track overrides: KEY@N=value applies to track N
+ for t in "${tags[@]}"; do
+ case "$t" in
+ *"@"[0-9]*=*)
+ local key rest num val
+ key="${t%%@*}"
+ rest="${t#*@}"
+ num="${rest%%=*}"
+ val="${rest#*=}"
+ if [ "$num" = "$i" ]; then alltags+=("$key=$val"); fi
+ ;;
+ esac
+ done
+
+ if [ ${#alltags[@]} -gt 0 ]; then
+ printf '%s\n' "${alltags[@]}" > "$dir/tmp_$i.tags"
+ metaflac --remove-all-tags --import-tags-from="$dir/tmp_$i.tags" "$dir/tmp_$i.flac"
+ rm -f "$dir/tmp_$i.tags"
+ else
+ metaflac --remove-all-tags "$dir/tmp_$i.flac"
+ fi
+
+ # name it something deliberately messy so renaming is exercised
+ mv "$dir/tmp_$i.flac" "$dir/$(printf '%02d' "$track") ${titles[$((i-1))]}.flac"
+ done
+ echo " made $(basename "$dir") (${#titles[@]} tracks)"
+}
+
+# art <album-dir> <width> <height> <colour> <label>
+art() {
+ local dir="$STAGING/$1" w="$2" h="$3" colour="$4" label="$5"
+ ffmpeg -hide_banner -loglevel error -y -f lavfi \
+ -i "color=c=${colour}:s=${w}x${h}:d=1" -frames:v 1 \
+ -vf "drawtext=text='${label}':fontcolor=white:fontsize=$((w/12)):x=(w-text_w)/2:y=(h-text_h)/2" \
+ "$dir/source_art.png" 2>/dev/null \
+ || ffmpeg -hide_banner -loglevel error -y -f lavfi \
+ -i "color=c=${colour}:s=${w}x${h}:d=1" -frames:v 1 "$dir/source_art.png"
+}
+
+# embed <album-dir> -- embed source_art.png as a front cover in every track
+embed() {
+ local dir="$STAGING/$1" f
+ for f in "$dir"/*.flac; do
+ metaflac --import-picture-from="$dir/source_art.png" "$f"
+ done
+ rm -f "$dir/source_art.png"
+}
+
+# note <album-dir> <text> -- write FIXTURE.md
+note() { printf '%s\n' "$2" > "$STAGING/$1/FIXTURE.md"; }
+
+# rmclean <album-dir> <name...> -- remove paths (files or dirs), ignoring absence
+rmclean() { local d="$1"; shift; local n; for n in "$@"; do rm -rf "$d/$n"; done; }
+
+# ---------------------------------------------------------------------------
+# the tone pool
+# ---------------------------------------------------------------------------
+echo "==> generating tone pool"
+base pool_a 2 220
+base pool_b 3 330
+base pool_c 4 440
+
+# ---------------------------------------------------------------------------
+# fixtures
+# ---------------------------------------------------------------------------
+echo "==> building fixtures"
+
+# --- 1. clean, well-tagged single-disc album -------------------------------
+make "01-clean-single-disc" \
+ "t:First Light" "t:Second Wind" "t:Third Rail" \
+ any:ALBUM="Clean Single Disc" \
+ any:ALBUMARTIST="Test Artist" \
+ any:ARTIST="Test Artist" \
+ any:DATE="2015" \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 \
+ any:DISCNUMBER=1
+note "01-clean-single-disc" "# 01 clean single disc
+
+Tests: the happy path. No extra tags, no crazy names, album art absent.
+
+Expected:
+- Common metadata displays ALBUM / ALBUMARTIST / DATE = 2015.
+- No extra tags to strip (nothing to remove).
+- Cover step must offer CAA/Deezer/iTunes plus placeholder (no embedded art).
+- Names become 1-01_first-light.flac etc.
+- Lands in archive/test-artist/clean-single-disc."
+
+# --- 2. clean, well-tagged TWO-DISC album ---------------------------------
+make "02-multi-disc" \
+ "t:Disc One Opener" "t:Disc One Closer" \
+ any:ALBUM="Multi Disc Set" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2019" any:DISCNUMBER=1 \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
+make "02-multi-disc/Disc 2" \
+ "t:Disc Two Opener" "t:Disc Two Closer" \
+ any:ALBUM="Multi Disc Set" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2019" any:DISCNUMBER=2 \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
+note "02-multi-disc" "# 02 multi-disc
+
+Tests: DISC number handling and the always-on disc prefix.
+
+Expected:
+- Recognised as ONE album with 2 discs, not two albums.
+- Names: 1-01_disc-one-opener.flac, 1-02_disc-one-closer.flac,
+ 2-01_disc-two-opener.flac, 2-02_disc-two-closer.flac.
+- All four land in the same archive/test-artist/multi-disc directory."
+
+# --- 3. messy real-world tags --------------------------------------------
+make "03-messy-tags" \
+ "t:Track With Genre" "t:Track With Comment" \
+ any:ALBUM="Messy Tags" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2007-03-14" any:DISCNUMBER=1 \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 \
+ any:GENRE="IDM" any:COMMENT="downloaded from somewhere" any:RATING="5" \
+ any:LABEL="Some Label" any:CATALOGNUMBER="CAT-001" \
+ any:REPLAYGAIN_TRACK_GAIN="-3.2 dB" any:ENCODER="old ripper 0.1" \
+ any:MOOD="chill" any:BPM="120"
+note "03-messy-tags" "# 03 messy tags
+
+Tests: tag stripping and DATE normalisation.
+
+Expected:
+- Review shows the extra tags being REMOVED: GENRE, COMMENT, RATING, LABEL,
+ CATALOGNUMBER, REPLAYGAIN_TRACK_GAIN, ENCODER, MOOD, BPM.
+- DATE '2007-03-14' normalises to 2007.
+- After writing, only the 7 whitelisted tags remain.
+- Lands in archive/test-artist/messy-tags."
+
+# --- 4. non-Latin titles, collisions, empty normalisation ----------------
+make "04-unicode-and-symbols" \
+ "t:夜明け" "t:夜明け (Reprise)" "t:Café Münster" \
+ "t:Don't Stop" "t:Track (feat. Someone) [Remix]" "t:★★★★★" \
+ any:ALBUM="Unicode And Symbols" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2021" any:DISCNUMBER=1
+note "04-unicode-and-symbols" "# 04 unicode, symbols, collisions
+
+Tests: THE RISKY ONE. NFKD transliteration, lossy normalisation, collisions,
+and the name editor as the way out.
+
+Expected with --yes and no editor action:
+- '夜明け' and '★★★★★' both normalise to an empty name, so intake REFUSES to
+ guess rather than writing '1-06_.flac'. Nothing is written.
+
+Expected interactively (or with a scripted editor):
+- The editor opens on the names, with the empty ones shown blank and the
+ originals in the trailing comment.
+- Filling in names lets the album through; the TITLE tags keep the original
+ Japanese and symbols, because a name the user typed is a filename, not a
+ licence to rewrite the tag.
+- 'Café Münster' -> 'cafe-munster' (accent folding works), and
+ \"Don't Stop\" -> 'dont-stop' need no editing at all."
+
+# --- 5. mixed art and mixed formats --------------------------------------
+rmclean "$STAGING/05-art-and-formats" source_art.png cover.jpg README.txt notes.txt broken.flac broken.mp3
+make "05-art-and-formats" \
+ "t:Has Embedded Art" "t:Also Has Art" \
+ any:ALBUM="Art And Formats" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2012" any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:DISCNUMBER=1
+art "05-art-and-formats" 300 300 darkgreen "300x300"
+embed "05-art-and-formats"
+note "05-art-and-formats" "# 05 embedded art + a folder image + stray formats
+
+Tests: cover candidates and non-audio clutter.
+
+Expected:
+- Cover step offers the EMBEDDED 300x300 image as a candidate.
+- A separate cover.jpg is also present as a folder-image candidate.
+- The 300x300 embedded art is LOW resolution: intake should warn that a better
+ source exists and prefer/preview the larger candidates.
+- A 'broken.mp3' containing plain text and README.txt / notes.txt must be
+ ignored, not choke the scan."
+
+# a larger folder image + non-audio clutter
+art "05-art-and-formats" 1200 1200 navy "1200x1200"
+mv "$STAGING/05-art-and-formats/source_art.png" "$STAGING/05-art-and-formats/cover.jpg"
+printf 'clutter that must be ignored\n' > "$STAGING/05-art-and-formats/README.txt"
+printf 'more clutter\n' > "$STAGING/05-art-and-formats/notes.txt"
+printf 'not really an mp3\n' > "$STAGING/05-art-and-formats/broken.mp3"
+
+# --- 6. compilation / various artists ------------------------------------
+make "06-compilation" \
+ "t:Comp Track One" "t:Comp Track Two" "t:Comp Track Three" \
+ any:ALBUM="Big Compilation" any:ALBUMARTIST="Various Artists" \
+ any:DATE="2003" any:DISCNUMBER=1 \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 \
+ any:ARTIST@1="Artist One" any:ARTIST@2="Artist Two" any:ARTIST@3="Artist Three"
+note "06-compilation" "# 06 compilation / various artists
+
+Tests: per-track ARTIST differing, ALBUMARTIST = Various Artists.
+
+Expected:
+- Review must show ARTIST varies per track (not hidden as 'common').
+- Directory key is ALBUMARTIST, i.e. archive/various-artists/big-compilation
+ (NOT artist-one/big-compilation).
+- Per-track ARTIST is preserved in the written tags."
+
+# --- 7. no ARTIST tag at all (only ALBUMARTIST) --------------------------
+make "07-missing-artist" \
+ "t:Only Album Artist" "t:Still Only Album Artist" \
+ any:ALBUM="Missing Artist" any:ALBUMARTIST="Fallback Artist" \
+ any:DATE="1999" any:DISCNUMBER=1 any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
+note "07-missing-artist" "# 07 missing ARTIST
+
+Tests: ARTIST fallback.
+
+Expected:
+- ARTIST is absent on every track; intake fills it from ALBUMARTIST.
+- Review should say so explicitly rather than silently inventing it.
+- Lands in archive/fallback-artist/missing-artist."
+
+# --- 8. tags stripped bare: filename is the only clue -------------------
+make "08-no-tags-at-all" \
+ "t:Untagged One" "t:Untagged Two" "t:Untagged Three" \
+ any:DISCNUMBER=1
+note "08-no-tags-at-all" "# 08 no usable tags
+
+Tests: fully untagged audio (only DISCNUMBER present). The editor is the way to
+supply everything.
+
+Expected:
+- ALBUM and ALBUMARTIST are missing, so intake does NOT invent them from the
+ directory name; it opens the editor with 'album:' and 'artist:' blank.
+- Track numbers are missing: inferred from the leading filename digits, and the
+ inference is shown.
+- Whatever the user types becomes both the tags and the folders.
+- Nothing is written to archive/ until that happens."
+
+# --- 9. 120 tracks: zero-padding past 99 --------------------------------
+specs=()
+for n in $(seq 1 120); do specs+=("t:Long Player Track $n"); done
+specs+=("any:ALBUM=Hundred Twenty" "any:ALBUMARTIST=Test Artist" "any:ARTIST=Test Artist"
+ "any:DATE=2020" "any:DISCNUMBER=1")
+make "09-past-ninety-nine" "${specs[@]}"
+note "09-past-ninety-nine" "# 09 more than 99 tracks
+
+Tests: zero-padding width decision.
+
+Expected:
+- With 120 tracks, 2-digit padding is impossible to keep unique AND consistent.
+ intake should widen the whole album to 3 digits (1-001_... through 1-120_...)
+ rather than mixing 1-99 and 1-100 widths.
+- This fixture exists to make that decision concrete."
+
+# --- 10. unusual album/artist names for directory normalisation ---------
+make "10-naming-edge-cases" \
+ "t:Opener" "t:Closer" \
+ any:ALBUM="Álbum: The (Deluxe) Edition!" \
+ any:ALBUMARTIST="Björk & Sigur Rós" \
+ any:ARTIST="Björk & Sigur Rós" \
+ any:DATE="2001" any:DISCNUMBER=1 any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
+note "10-naming-edge-cases" "# 10 directory-name edge cases
+
+Tests: normalisation of artist and album into directory names.
+
+Expected:
+- ALBUMARTIST 'Björk & Sigur Rós' -> 'bjork-and-sigur-ros' (or 'bjork-sigur-ros';
+ the exact treatment of '&' is what this fixture pins down).
+- ALBUM 'Álbum: The (Deluxe) Edition!' -> 'album-the-deluxe-edition'.
+- Confirm the review shows the proposed directories BEFORE creating them."
+
+# --- 10b. artist credit with no ASCII form ------------------------------
+make "10b-unromanisable-artist" \
+ "t:First Track" "t:Second Track" \
+ any:ALBUM="夜明け" any:ALBUMARTIST="椎名林檎" any:ARTIST="椎名林檎" \
+ any:DATE="2019" any:DISCNUMBER=1 any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
+note "10b-unromanisable-artist" "# 10b a credit and title with no ASCII form
+
+Tests: the name editor and unromanisable artist resolution. Kanji carry no
+readings in Unicode, so neither the artist folder nor the album folder can be
+derived automatically.
+
+Expected:
+- The artist cannot be romanised, so intake asks for a folder name (or, under
+ --yes, uses a placeholder and defers to the editor).
+- The editor opens with 'album:' and 'artist:' blank and the hints explaining
+ why, plus a track line whose name is empty after the generated prefix.
+- Whatever the user types becomes the folder names; the ALBUM and ALBUMARTIST
+ TAGS keep the original Japanese text, so no metadata is lost.
+- The decision is written to artists.toml, so the next album by this artist
+ resolves with no prompt."
+
+# --- 11. empty staging entry (not an album) -----------------------------
+mkdir -p "$STAGING/11-empty-dir"
+note "11-empty-dir" "# 11 empty directory
+
+Tests: an empty (or later, partially-downloaded) directory in staging.
+
+Expected:
+- scan reports it as not-an-album / incomplete and SKIPS it.
+- intake album on it refuses with a clear message, exits non-zero."
+
+# --- 12. single track ---------------------------------------------------
+make "12-single-track" \
+ "t:The Only Song" \
+ any:ALBUM="Just One Song" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2022" any:DISCNUMBER=1 any:TRACKNUMBER=1
+note "12-single-track" "# 12 single-track release
+
+Tests: degenerate album (one track). Common metadata must still display.
+
+Expected:
+- Works exactly like any other album with one track.
+- Lands in archive/test-artist/just-one-song/1-01_the-only-song.flac."
+
+# --- 13. long titles for filesystem limits ------------------------------
+make "13-long-and-odd-names" \
+ "t:A Very Long Track Title That Goes On And On And Should Still Be Handled Gracefully Without Blowing Up The Filesystem Limit Because That Would Be Bad" \
+ "t:This Title Is Absurdly Long On Purpose To Force The Normaliser To Actually Truncate The Resulting Filename Rather Than Merely Come Close To The Limit Which Would Not Test Anything Useful At All" \
+ "t:trailing dots..." \
+ "t: leading and trailing spaces " \
+ "t:MIXED Case TiTlE" \
+ any:ALBUM="Long And Odd Names" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
+ any:DATE="2018" any:DISCNUMBER=1
+note "13-long-and-odd-names" "# 13 long / odd / whitespace titles
+
+Tests: filename length limits and whitespace hygiene.
+
+Expected:
+- The second track's normalised name would exceed 200 bytes, so intake must
+ TRUNCATE it, preserve the .flac extension, and show the shortening as a
+ warning before writing.
+- The first track is long but under the cap, so it must NOT be truncated.
+- Trailing dots and leading/trailing spaces are stripped (they break Windows and
+ are invisible).
+- 'MIXED Case TiTlE' -> 'mixed-case-title'."
+
+make "14-compilation-untagged" \
+ "t:Split One" "t:Split Two" "t:Split Three" "t:Split Four" \
+ any:ALBUM="Untagged Compilation" any:DATE="2001" any:DISCNUMBER=1 \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 any:TRACKNUMBER@4=4 \
+ any:ARTIST@1="One Artist" any:ARTIST@2="Two Artist" \
+ any:ARTIST@3="Three Artist" any:ARTIST@4="Four Artist"
+note "14-compilation-untagged" "# 14 compilation with no ALBUMARTIST
+
+Tests: a genuine compilation that never had an ALBUMARTIST tag.
+
+Expected:
+- Files under archive/various-artists/untagged-compilation. No majority artist
+ among four different credits, so this is the various-artists case.
+- Must NOT be refused. The album artist is only unknown when there is also no
+ reason to think it is a compilation; four different credits is that reason."
+
+make "15-guest-tracks" \
+ "t:Solo One" "t:Solo Two" "t:Solo Three" "t:With A Guest" \
+ any:ALBUM="Guests" any:DATE="2012" any:DISCNUMBER=1 \
+ any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 any:TRACKNUMBER@4=4 \
+ "any:ALBUM ARTIST=Main Artist" \
+ any:ARTIST@1="Main Artist" any:ARTIST@2="Main Artist" \
+ any:ARTIST@3="Main Artist" any:ARTIST@4="Main Artist + Guest"
+note "15-guest-tracks" "# 15 album with guest tracks
+
+Tests: an album whose tracks credit a guest, tagged ALBUM ARTIST with a space.
+
+Expected:
+- 'ALBUM ARTIST' means the same as ALBUMARTIST, so it is used and must not
+ appear in the list of tags being removed.
+- Files under archive/main-artist/guests, NOT various-artists. One track
+ crediting a guest does not make an album a compilation.
+- Per-track ARTIST keeps the guest credit on the written tags."
+
+echo
+echo "==> done. Fixtures in $STAGING"
+echo "==> albums:"
+for d in "$STAGING"/*/; do
+ n=$(find "$d" -name '*.flac' | wc -l)
+ printf ' %-28s %3s tracks\n' "$(basename "$d")" "$n"
+done
+echo
+echo "NOTE: 11-empty-dir is intentionally empty."
diff --git a/scripts/tui-tour.py b/scripts/tui-tour.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Drive the TUI in a pty and print each screen with ANSI stripped.
+
+Usage: scripts/tui-tour.py [--root DIR] [--width N] [--height N] [--make-fixtures]
+"""
+import argparse
+import fcntl
+import os
+import pty
+import re
+import select
+import struct
+import sys
+import termios
+import time
+
+ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
+OSC = re.compile(r"\x1b\][^\x07]*\x07")
+CHARSET = re.compile(r"\x1b[()][A-Z0-9]")
+
+
+def clean(data: bytes) -> list[str]:
+ """Strip escapes and keep the last paint of each line."""
+ text = CHARSET.sub("", OSC.sub("", ANSI.sub("", data.decode("utf-8", "replace"))))
+ out: list[str] = []
+ for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
+ line = line.rstrip()
+ if line.strip() and (not out or out[-1] != line):
+ out.append(line)
+ return out
+
+
+class Session:
+ def __init__(self, args):
+ self.args = args
+ self.pid, self.fd = pty.fork()
+ if self.pid == 0:
+ os.environ["INTAKE_THEME"] = args.theme
+ os.environ["TERM"] = "xterm-256color"
+ os.execv(args.binary, [
+ args.binary, "tui",
+ "--root", args.root,
+ "--state-dir", os.path.join(args.root, "state"),
+ ])
+ fcntl.ioctl(self.fd, termios.TIOCSWINSZ,
+ struct.pack("HHHH", args.height, args.width, 0, 0))
+
+ def drain(self, seconds: float) -> bytes:
+ out = b""
+ end = time.time() + seconds
+ while time.time() < end:
+ ready, _, _ = select.select([self.fd], [], [], 0.25)
+ if ready:
+ try:
+ out += os.read(self.fd, 65536)
+ except OSError:
+ break
+ return out
+
+ def send(self, keys: bytes) -> bytes:
+ os.write(self.fd, keys)
+ return self.drain(self.args.wait)
+
+ def close(self):
+ try:
+ os.write(self.fd, b"q")
+ time.sleep(0.3)
+ except OSError:
+ pass
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--root", default="/tmp/intake-tour")
+ ap.add_argument("--binary", default="./out/intake")
+ ap.add_argument("--width", type=int, default=104)
+ ap.add_argument("--height", type=int, default=34)
+ ap.add_argument("--wait", type=float, default=3.0)
+ ap.add_argument("--theme", default="dark")
+ ap.add_argument("--make-fixtures", action="store_true",
+ help="generate fixtures into <root>/staging first")
+ args = ap.parse_args()
+
+ if args.make_fixtures:
+ os.makedirs(os.path.join(args.root, "staging"), exist_ok=True)
+ os.system(f"bash scripts/make-fixtures.sh {args.root}/staging >/dev/null 2>&1")
+
+ s = Session(args)
+ print(f"===== BROWSE ({args.width}x{args.height}) =====")
+ print("\n".join(clean(s.drain(4))))
+
+ for label, keys in [
+ ("METADATA", b"\r"),
+ ("TRACK NAMES", b"\r"),
+ ("COVER ART", b"\r"),
+ ("COVER PICKER", b"\r"),
+ ]:
+ print(f"\n===== {label} =====")
+ print("\n".join(clean(s.send(keys))))
+
+ s.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())