Files
yellowjacket/backend/download/importer.go
T
logan 4b9114fd8d
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 6m18s
fix(tagwriter): declare the track and disc totals when tagging
An album the user holds 2 of 10 tracks of showed a green tick reading
"is in your library", and the mechanism was our own writer. tagwriter
wrote track and disc *numbers* and dropped the totals, so autotagging a
folder made the release MBID-matched -- which is what earns the tick --
while erasing the one field GetAlbumCompleteness reads. The evidence
for "2 of 10" was destroyed by the act that produced the tick.

FieldTotalTracks and FieldTotalDiscs are written as the ID3 "n/N" form
and as Vorbis TRACKTOTAL/DISCTOTAL; the autotag apply pass and the
download importer fill them from the release's own tracklist; and
dbsync persists the track total to audio_files.total_tracks so the
album page agrees with the file without waiting for a rescan.

Five things about it are load-bearing, and four fail silently:

- The total is per *disc*, not per release, because that is what the
  tag form declares and what GetAlbumCompleteness sums per disc. A
  release total on every file multiplies a two-disc album's expectation
  by two, which no library can satisfy. backend/tagtotals is that
  derivation once, since the two callers must not import the writer or
  each other.
- The Vorbis names are TRACKTOTAL and DISCTOTAL and no other spelling.
  dhowden/tag reads exactly those two keys, so TOTALTRACKS -- which
  xiph lists and several taggers write -- or a "1/12" packed into
  TRACKNUMBER writes successfully and reads back as no total at all.
  The tests therefore assert the round trip through the reader the scan
  uses, not through the bytes.
- ID3's number and total share one frame, so writing either alone must
  read the other off the existing tag or discard it. A total with no
  number is not written: "/12" parses as track 0.
- The totals are written unconditionally rather than on a diff. The
  case this exists for is a file declaring no total at all, which
  compares equal to nothing and is exactly what a "only if it changed"
  guard skips.
- A single-track download is not totalled. A RecordingMBID anchor
  resolves Expected to that one track, so the same code would tag a
  track off a twelve-track album "1 of 1" -- and a declared total
  outranks the catalog total that would have answered correctly.

autotag's field constants are a second copy of tagwriter's, deliberately
so autotag stays out of the write pipeline's import graph. A key that
drifts neither fails to compile nor fails to write -- the writer simply
finds nothing under the name it looks for -- so autotagservice, the one
package importing both, now pins them.

Steps 2 and 3 of the issue stay open under #38: the catalog fallback
already landed as completenessAnswer(), and the badge call-site audit is
the part that overlaps it.

Closes #16
2026-08-18 18:19:54 -04:00

575 lines
14 KiB
Go

package download
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"yellowjacket/backend/tagtotals"
"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
}
// An imported file should arrive knowing how much of the album it
// is one of, or the album reads as "in your library" from its first
// imported track onward.
//
// A *track* download is the case this must not touch: a
// RecordingMBID anchor resolves Expected to exactly that one track,
// so totalling it would write "1 of 1" onto a track off a
// twelve-track album -- a confident lie, and one that outranks the
// catalog's own total, which is the fallback that would otherwise
// have answered correctly.
if dl.RecordingMBID == "" {
if tracks, discs := tagtotals.For(
expectedPositions(dl.Expected), p.Track.DiscNumber,
); tracks > 0 {
changes[tagwriter.FieldTotalTracks] = tracks
changes[tagwriter.FieldTotalDiscs] = discs
}
}
if err := i.tags.WriteUntrackedFileTags(p.Source, changes); err != nil {
return fmt.Errorf("write tags: %w", err)
}
return nil
}
// expectedPositions is the download's resolved tracklist as bare
// positions.
func expectedPositions(expected []ExpectedTrack) []tagtotals.Position {
out := make([]tagtotals.Position, 0, len(expected))
for _, t := range expected {
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
}
return out
}
// 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
}