Files
bvandeusenandClaude Opus 5.5 80ecc2f7e7
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: imported FLAC, Ogg and Opus files keep their track and disc numbers (#6171)
The importer wrote track=N/M for every format, which ffmpeg stores in a
Vorbis comment as TRACKNUMBER=3/20; dhowden/tag reads a Vorbis TRACKNUMBER
with a bare Atoi, so it came back 0. All 245 tracks imported since
2026-10-10 (Humanz, Plastic Beach, the Gorillaz super deluxe and twelve
more albums) have no track or disc number, and their album pages have no
order.

- The importer writes a Vorbis track and disc number alone, with
  TRACKTOTAL and DISCTOTAL, as Picard does. ID3 and MP4 keep N/M.
- ReadTrackAndDisc takes the number before a slash when the Vorbis reader
  returns 0, for the scanner and for the import's own check of a download.
- tagReadVersion 6 re-reads the files already held (tag reads only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:49:44 -04:00

177 lines
5.0 KiB
Go

package importer
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/dhowden/tag"
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// Printer says which MusicBrainz recordings a file's audio is. ErrNoPrints:
// it cannot say for any file (AcoustID is not set up, or not answering), so
// the check rests on lengths and tags.
type Printer interface {
Recordings(ctx context.Context, path string) ([]string, error)
}
// ErrNoPrints: fingerprints are not available for this download.
var ErrNoPrints = errors.New("importer: fingerprints are not available")
// AcoustIDPrinter fingerprints with fpcalc and asks AcoustID, under the
// operator's settings (key, minimum score). Each call reads the settings, so
// a change takes effect on the next file.
type AcoustIDPrinter struct {
Settings interface {
Get() library.AcoustIDSettings
}
Client *acoustid.Client
}
// Recordings is every recording AcoustID gives at or above the minimum score:
// one audio can be several recordings (the album track and the single).
func (p *AcoustIDPrinter) Recordings(ctx context.Context, path string) ([]string, error) {
s := p.Settings.Get()
if !s.Ready() || p.Client == nil {
return nil, ErrNoPrints
}
fp, dur, err := library.LookupFingerprint(ctx, path)
if err != nil {
if library.IsInconclusive(err) {
return nil, ErrNoPrints
}
return nil, nil // fpcalc rejected the file: no evidence either way
}
cands, err := p.Client.Lookup(ctx, s.APIKey, fp, dur)
switch {
case errors.Is(err, acoustid.ErrInvalidKey), errors.Is(err, acoustid.ErrUnavailable):
return nil, ErrNoPrints
case err != nil:
return nil, nil
}
var out []string
for _, c := range cands {
if c.Score >= float64(s.MinScore) && c.ID != "" {
out = append(out, c.ID)
}
}
return out, nil
}
// formatRank picks which format's files are the album when a download holds
// more than one (a FLAC folder and an MP3 folder): the most files, then the
// better format.
var formatRank = map[string]int{".flac": 6, ".wav": 5, ".m4a": 4, ".opus": 3, ".ogg": 2, ".mp3": 1, ".aac": 0}
// audioFiles lists the audio files under root (a folder, or one file), in
// the one format that is the album. Dot-named files and folders are skipped:
// they are partial downloads, OS litter or Minstrel's own temp files.
func audioFiles(root string) ([]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
if library.IsAudioFile(root) {
return []string{root}, nil
}
return nil, nil
}
byExt := map[string][]string{}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasPrefix(d.Name(), ".") && path != root {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !d.IsDir() && library.IsAudioFile(path) {
ext := strings.ToLower(filepath.Ext(path))
byExt[ext] = append(byExt[ext], path)
}
return nil
})
if err != nil {
return nil, err
}
best := ""
for ext, paths := range byExt {
if best == "" || len(paths) > len(byExt[best]) ||
(len(paths) == len(byExt[best]) && formatRank[ext] > formatRank[best]) {
best = ext
}
}
out := byExt[best]
sort.Strings(out)
return out, nil
}
// readFile reads what one file's tags and length say. A file whose tags
// cannot be read still counts, by its length and name.
func readFile(ctx context.Context, root, path string) File {
f := File{Path: path, Rel: rel(root, path), Video: len(library.SourceMarkersFor(path)) > 0}
if ms, err := library.ProbeDurationMs(ctx, path); err == nil {
f.LengthMs = int(ms)
}
h, err := os.Open(path)
if err != nil {
return f
}
defer func() { _ = h.Close() }()
m, err := tag.ReadFrom(h)
if err != nil {
return f
}
f.Title, f.Artist, f.AlbumArtist, f.Album = m.Title(), m.Artist(), m.AlbumArtist(), m.Album()
f.Track, f.Disc = library.ReadTrackAndDisc(m)
ids := library.ReadTagMBIDs(m)
f.GroupMBID, f.ReleaseMBID, f.RecordingMBID = ids.ReleaseGroup, ids.Release, ids.Recording
return f
}
func rel(root, path string) string {
if r, err := filepath.Rel(root, path); err == nil && r != "." {
return r
}
return filepath.Base(path)
}
// inspect reads every audio file under root and asks the printer about each.
// fingerprinted reports whether the printer answered at all.
func inspect(ctx context.Context, root string, p Printer) (files []File, fingerprinted bool, err error) {
paths, err := audioFiles(root)
if err != nil {
return nil, false, err
}
fingerprinted = p != nil
for _, path := range paths {
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
f := readFile(ctx, root, path)
if fingerprinted && len(paths) <= maxFiles {
recs, err := p.Recordings(ctx, path)
if errors.Is(err, ErrNoPrints) {
fingerprinted = false
for i := range files {
files[i].Recordings = nil
}
} else {
f.Recordings = recs
}
}
files = append(files, f)
}
return files, fingerprinted, nil
}