Files
minstrel/internal/importer/inspect.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -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
// AcoustID 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
}