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
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>
177 lines
5.0 KiB
Go
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
|
|
}
|