Files
yellowjacket/backend/download/importer.go
T
yonluandClaude Opus 5.5 ef89707bb1 feat(download): fill in the tracks a nearly complete album is missing
A grab that delivers nine of twelve tracks clears the completeness floor
and is imported, and the other three were never looked for. On Soulseek
that is the commonest way an album ends up almost right: one peer's
folder lacks a track, or one file fails.

After a successful import the manager now compares the tracks the files
were aligned to (ImportResult.Matched, new) with the expected tracklist.
When one to three are missing, and fewer than half, it searches for each
one on its own, as the track's artist and title with the album kept for
ranking. It grabs the first auto-acceptable copy that is not from the
source that already failed to supply it, and is not a delegate, which
would place it in its own library. The candidate is trimmed to the one
file aligned to the track. The import uses the album's own request with
ImportOptions.Only, which skips the completeness check and imports only
a file aligned to the missing track, so it is tagged and placed as part
of the album, and anything else the folder brought is left out.

One attempt per track, and nothing here fails the download: the album
is already imported, so a track that cannot be found is logged on the
job and left. slskd accepts one-file folders for a request that expects
one track, which the per-track search needs.

Closes #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
2026-09-26 22:11:03 -04:00

634 lines
16 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
// Only, when set, imports just the files that align to these
// tracks of the download and skips the completeness check: it is a
// fill-in for tracks an earlier grab of the same album did not
// deliver (#276), tagged and placed as part of that album.
Only []ExpectedTrack
}
// 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
// Matched are the expected tracks an imported file was aligned to,
// which is how a caller learns what the grab did not deliver.
Matched []ExpectedTrack
}
// 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 len(opts.Only) == 0 {
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)
if len(opts.Only) > 0 {
plan = onlyTracks(plan, opts.Only)
if len(plan) == 0 {
return ImportResult{}, fmt.Errorf(
"%w: no file matched the missing track", ErrTooIncomplete,
)
}
}
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)
if p.Matched {
out.Matched = append(out.Matched, p.Track)
}
}
return out, nil
}
// trackKey identifies an expected track within a release.
type trackKey struct{ disc, position int }
func keyOf(t ExpectedTrack) trackKey {
return trackKey{disc: t.DiscNumber, position: t.Position}
}
// onlyTracks keeps the planned files aligned to one of want. A fill-in
// grab can bring more than the one file it was after — a folder where
// the title also matched a live take — and anything else would land in
// the album as a duplicate or a stranger.
func onlyTracks(plan []plannedFile, want []ExpectedTrack) []plannedFile {
keys := make(map[trackKey]bool, len(want))
for _, t := range want {
keys[keyOf(t)] = true
}
out := make([]plannedFile, 0, len(want))
seen := map[trackKey]bool{}
for _, p := range plan {
k := keyOf(p.Track)
if !p.Matched || !keys[k] || seen[k] {
continue
}
seen[k] = true
out = append(out, p)
}
return out
}
// 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
}