release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A finished download is checked against MusicBrainz before anything is filed: the files are paired with an edition's tracks by position or path, and AcoustID, tag ids, lengths and titles say whether it is the album. What is the album is copied into the library as Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's MusicBrainz ids, and scanned; the download keeps seeding. What is not is refused: set aside, removed from the client, and the album searched for again. A download the check cannot settle waits for a person, who imports it anyway or refuses it. Tracks the library already holds are skipped. The wanted list leaves Admin for a top-level Wanted view, admin-only and web-only, with tabs for Wanted, Activity (downloads and their imports, the ones needing a person first), Missing files, Held, Following (with the release-type profiles, moved off Integrations) and Blocked. The search settings gain "File new music into" when the library has several folders. The search pass logs why each failed search failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
5.5 KiB
Go
196 lines
5.5 KiB
Go
package importer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/dhowden/tag"
|
|
"github.com/dhowden/tag/mbz"
|
|
|
|
"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, _ = m.Track()
|
|
f.Disc, _ = m.Disc()
|
|
info := mbz.Extract(m)
|
|
f.GroupMBID = clean(info.Get(mbz.ReleaseGroup))
|
|
f.ReleaseMBID = clean(info.Get(mbz.Album))
|
|
f.RecordingMBID = clean(info.Get(mbz.Recording))
|
|
if f.RecordingMBID == "" && m.Format() == tag.VORBIS {
|
|
// Picard's Vorbis key for the recording is MUSICBRAINZ_TRACKID, which
|
|
// mbz files under its release-track name.
|
|
f.RecordingMBID = clean(info.Get(mbz.Track))
|
|
}
|
|
return f
|
|
}
|
|
|
|
func rel(root, path string) string {
|
|
if r, err := filepath.Rel(root, path); err == nil && r != "." {
|
|
return r
|
|
}
|
|
return filepath.Base(path)
|
|
}
|
|
|
|
// clean takes the first of NUL-separated values: ID3v2.4 multi-values.
|
|
func clean(s string) string {
|
|
for _, part := range strings.Split(s, "\x00") {
|
|
if t := strings.TrimSpace(part); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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
|
|
}
|