Files
yellowjacket/backend/download/importer.go
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 14:35:57 -04:00

543 lines
13 KiB
Go

package download
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"yellowjacket/backend/tagwriter"
)
// The import step is the only writer into library paths. Everything
// before it happens in staging, where a bad download is a directory to
// delete rather than a row to un-ingest.
//
// Order matters: tags are written while the files are still staged, so
// the scanner's first sight of a file is already correct. Tagging
// after the move would mean a window where the library holds a track
// titled "01 - Track01.flac", and the user would watch it fix itself.
// Import errors.
var (
// ErrNoAudio means the grab produced no playable audio files.
ErrNoAudio = errors.New("download contained no audio files")
// ErrTooIncomplete means too few of the expected tracks arrived to
// call the download successful.
ErrTooIncomplete = errors.New("download is missing too many tracks")
// ErrDestinationExists means the computed library path is already
// occupied by a different file.
ErrDestinationExists = errors.New("destination file already exists")
)
// minCompleteness is the fraction of the expected tracklist that must
// arrive for an anchored import to proceed. Below this the download is
// a different thing than what was asked for — a single, a sampler, a
// partial transfer — and quietly importing it would corrupt the
// library's idea of the album.
const minCompleteness = 0.8
// TagWriterPort is the tag-writing capability the importer needs.
// Narrow interface rather than *tagwriter.TagWriter so importer tests
// do not need a database.
type TagWriterPort interface {
WriteUntrackedFileTags(filePath string, changes tagwriter.TagChanges) error
}
// LibraryPort is the library-side capability the importer needs.
type LibraryPort interface {
// ScanLibrary triggers a rescan so imported files are ingested.
ScanLibrary(id int64) error
// LibraryPath resolves a library's root directory by id.
LibraryPath(id int64) (string, error)
}
// ImportOptions configures how imported files are laid out.
type ImportOptions struct {
// LibraryRoot is the directory imported files are placed under.
// Resolved per-request from the request's LibraryID — never a
// fixed, app-wide directory, since a user can have several
// libraries.
LibraryRoot string
// PathTemplate lays out the destination path. Supported tokens:
// {albumartist} {artist} {album} {year} {track} {disc} {title}.
// Empty means flat: everything into LibraryRoot/{albumartist}/{album}.
PathTemplate string
// WriteTags controls whether the importer tags files before moving
// them. Off for delegate providers, which have already imported
// and tagged the files themselves.
WriteTags bool
}
// DefaultPathTemplate is the layout used when none is configured.
const DefaultPathTemplate = "{albumartist}/{album}/{track} {title}"
// Importer moves verified downloads into the library.
type Importer struct {
logger *slog.Logger
staging *Staging
tags TagWriterPort
library LibraryPort
}
// NewImporter builds an importer.
func NewImporter(
logger *slog.Logger,
staging *Staging,
tags TagWriterPort,
library LibraryPort,
) *Importer {
return &Importer{
logger: logger,
staging: staging,
tags: tags,
library: library,
}
}
// ImportResult reports what an import placed where.
type ImportResult struct {
// Paths are the library paths files ended up at.
Paths []string
// Tagged counts files whose tags were rewritten.
Tagged int
// Skipped counts non-audio files left in staging (logs, cue sheets,
// scene .nfo files) — deliberately not imported.
Skipped int
}
// Import verifies, tags and moves a completed grab into the library.
//
// On any failure the staging directory is left intact so the user can
// retry or inspect it; only a fully successful import releases staging.
func (i *Importer) Import(
ctx context.Context,
dl Download,
result Result,
opts ImportOptions,
) (ImportResult, error) {
files, err := i.staging.Verify(result.Dir, result.Files)
if err != nil {
return ImportResult{}, err
}
audio, skipped := splitAudio(files)
if len(audio) == 0 {
return ImportResult{}, ErrNoAudio
}
if err := checkCompleteness(len(audio), dl); err != nil {
return ImportResult{}, err
}
// Align staged files to the expected tracklist so tags and
// filenames reflect the release, not the uploader's naming.
plan := i.planFiles(audio, dl)
out := ImportResult{
Paths: make([]string, 0, len(plan)),
Skipped: skipped,
}
for _, p := range plan {
if err := ctx.Err(); err != nil {
return out, fmt.Errorf("import cancelled: %w", err)
}
if opts.WriteTags {
if err := i.tagFile(p, dl); err != nil {
// A file that cannot be tagged is still worth importing
// — the scanner will read whatever tags it has, and the
// autotag queue can pick it up later. Losing the whole
// album over one unwritable file would be worse.
i.logger.Warn(
"could not tag downloaded file before import",
"path", p.Source,
"error", err,
)
} else {
out.Tagged++
}
}
dest, err := i.destinationFor(p, dl, opts)
if err != nil {
return out, err
}
if err := movePath(p.Source, dest); err != nil {
return out, err
}
out.Paths = append(out.Paths, dest)
}
return out, nil
}
// plannedFile pairs a staged file with the expected track it matched.
type plannedFile struct {
Source string
// Track is the matched expected track, or the zero value when the
// file could not be aligned (free-text requests, bonus tracks).
Track ExpectedTrack
Matched bool
}
// planFiles aligns staged files to the expected tracklist.
func (i *Importer) planFiles(audio []string, dl Download) []plannedFile {
files := make([]CandidateFile, 0, len(audio))
for _, a := range audio {
format, isAudio := FormatForPath(a)
files = append(files, CandidateFile{
Path: a,
Format: format,
IsAudio: isAudio,
})
}
matched, _ := matchFiles(files, dl.Expected)
byPosition := make(map[int]ExpectedTrack, len(dl.Expected))
for _, e := range dl.Expected {
byPosition[e.Position] = e
}
out := make([]plannedFile, 0, len(matched))
for _, m := range matched {
p := plannedFile{Source: m.Path}
if t, ok := byPosition[m.MatchedTo]; ok && m.MatchedTo != 0 {
p.Track = t
p.Matched = true
}
out = append(out, p)
}
// Stable order: matched tracks by position, then unmatched by path,
// so a partial import is reproducible.
sort.SliceStable(out, func(a, b int) bool {
if out[a].Matched != out[b].Matched {
return out[a].Matched
}
if out[a].Matched {
if out[a].Track.DiscNumber != out[b].Track.DiscNumber {
return out[a].Track.DiscNumber < out[b].Track.DiscNumber
}
return out[a].Track.Position < out[b].Track.Position
}
return out[a].Source < out[b].Source
})
return out
}
// tagFile writes the release's metadata onto a staged file.
func (i *Importer) tagFile(p plannedFile, dl Download) error {
if i.tags == nil || !p.Matched {
return nil
}
changes := tagwriter.TagChanges{
tagwriter.FieldAlbum: dl.Album,
tagwriter.FieldAlbumArtist: dl.Artist,
tagwriter.FieldTitle: p.Track.Title,
tagwriter.FieldTrackNumber: p.Track.Position,
}
if p.Track.Artist != "" {
changes[tagwriter.FieldArtist] = p.Track.Artist
} else {
changes[tagwriter.FieldArtist] = dl.Artist
}
if p.Track.DiscNumber > 0 {
changes[tagwriter.FieldDiscNumber] = p.Track.DiscNumber
}
if err := i.tags.WriteUntrackedFileTags(p.Source, changes); err != nil {
return fmt.Errorf("write tags: %w", err)
}
return nil
}
// destinationFor computes a file's library path from the template.
func (i *Importer) destinationFor(
p plannedFile,
dl Download,
opts ImportOptions,
) (string, error) {
if opts.LibraryRoot == "" {
return "", fmt.Errorf(
"%w: no library root configured", ErrNotConfigured,
)
}
tmpl := opts.PathTemplate
if tmpl == "" {
tmpl = DefaultPathTemplate
}
ext := filepath.Ext(p.Source)
title := p.Track.Title
if title == "" {
// Unmatched file: keep the uploader's name rather than
// inventing one, so nothing is silently renamed to a track it
// may not be.
title = strings.TrimSuffix(filepath.Base(p.Source), ext)
}
artist := p.Track.Artist
if artist == "" {
artist = dl.Artist
}
repl := strings.NewReplacer(
"{albumartist}", sanitizePathPart(fallback(dl.Artist, "Unknown Artist")),
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
"{album}", sanitizePathPart(fallback(dl.Album, "Unknown Album")),
"{title}", sanitizePathPart(title),
"{track}", trackToken(p.Track.Position),
"{disc}", strconv.Itoa(p.Track.DiscNumber),
"{year}", "",
)
rel := repl.Replace(tmpl)
// Clean up any empty segments left by unset tokens.
parts := make([]string, 0, 4)
for _, seg := range strings.Split(rel, "/") {
seg = strings.TrimSpace(seg)
if seg != "" {
parts = append(parts, seg)
}
}
if len(parts) == 0 {
return "", fmt.Errorf(
"%w: path template produced an empty path", ErrNotConfigured,
)
}
dest := filepath.Join(opts.LibraryRoot, filepath.Join(parts...)) + ext
return uniqueDestination(dest)
}
// uniqueDestination returns dest, or a numbered variant when dest is
// taken. Overwriting is never right here: the existing file may be a
// better copy the user already owns, and the download is not
// authoritative just because it arrived later.
func uniqueDestination(dest string) (string, error) {
const maxAttempts = 50
ext := filepath.Ext(dest)
base := strings.TrimSuffix(dest, ext)
for n := range maxAttempts {
candidate := dest
if n > 0 {
candidate = base + " (" + strconv.Itoa(n+1) + ")" + ext
}
_, err := os.Stat(candidate)
if os.IsNotExist(err) {
return candidate, nil
}
if err != nil {
return "", fmt.Errorf("stat destination: %w", err)
}
}
return "", fmt.Errorf("%w: %s", ErrDestinationExists, dest)
}
// movePath moves a file, falling back to copy+remove when the staging
// area and the library are on different filesystems — which is the
// normal case, since staging lives in the user data directory.
func movePath(src, dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
if err := os.Rename(src, dest); err == nil {
return nil
}
if err := copyFile(src, dest); err != nil {
return err
}
if err := os.Remove(src); err != nil {
// The copy succeeded, so the import is good; a leftover staged
// file is swept later.
return nil //nolint:nilerr // staging sweep handles the leftover
}
return nil
}
// copyFile copies src to dest, writing to a temporary file first so an
// interrupted copy never leaves a partial file at a library path where
// the scanner would find it.
func copyFile(src, dest string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open downloaded file: %w", err)
}
defer func() { _ = in.Close() }()
tmp := dest + ".part"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return fmt.Errorf("create library file: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return fmt.Errorf("copy into library: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("close library file: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("finalize library file: %w", err)
}
return nil
}
// checkCompleteness rejects an anchored download that is missing too
// much of its tracklist.
func checkCompleteness(got int, dl Download) error {
if len(dl.Expected) == 0 {
return nil
}
ratio := float64(got) / float64(len(dl.Expected))
if ratio < minCompleteness {
return fmt.Errorf(
"%w: got %d of %d tracks",
ErrTooIncomplete, got, len(dl.Expected),
)
}
return nil
}
// splitAudio partitions verified files into audio and a count of the
// rest.
func splitAudio(files []string) (audio []string, skipped int) {
audio = make([]string, 0, len(files))
for _, f := range files {
if _, ok := FormatForPath(f); ok {
audio = append(audio, f)
continue
}
skipped++
}
return audio, skipped
}
// trackToken formats a track number as a zero-padded two-digit string,
// or empty when unknown.
func trackToken(n int) string {
if n <= 0 {
return ""
}
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
// fallback returns s, or alt when s is blank.
func fallback(s, alt string) string {
if strings.TrimSpace(s) == "" {
return alt
}
return s
}
// sanitizePathPart makes a string safe as a single path segment on
// every supported platform: Windows reserves characters that are legal
// on Linux, and a library synced between the two must not produce
// unopenable files.
func sanitizePathPart(s string) string {
const maxSegment = 120
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
b.WriteByte('_')
default:
if r < 0x20 {
continue
}
b.WriteRune(r)
}
}
out := strings.TrimSpace(b.String())
// Trailing dots and spaces are silently stripped by Windows, which
// turns "Vol. 2 " into a name that no longer round-trips.
out = strings.TrimRight(out, ". ")
if len(out) > maxSegment {
out = strings.TrimSpace(out[:maxSegment])
}
if out == "" {
return "Unknown"
}
return out
}