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 }