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 }