feat: import with verification, and a Wanted view of its own (M502 #5522)
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
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>
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||
)
|
||||
|
||||
// Import states, as grabs.import_state holds them.
|
||||
const (
|
||||
StateDownloading = "downloading"
|
||||
StateWaiting = "waiting"
|
||||
StateImporting = "importing"
|
||||
StateImported = "imported"
|
||||
StateRefused = "refused"
|
||||
StateReview = "review"
|
||||
StateHeld = "held"
|
||||
StateFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
// interval: a finished download is noticed within a minute. One
|
||||
// Torrents call per download client, so the client barely notices.
|
||||
interval = time.Minute
|
||||
// passTimeout bounds a pass: fingerprinting a long album against
|
||||
// AcoustID's pace takes a minute or two, so half an hour is a hang.
|
||||
passTimeout = 30 * time.Minute
|
||||
// staleMinutes: an import still "importing" after this was cut short
|
||||
// (a restart mid-copy) and is tried again.
|
||||
staleMinutes = 30
|
||||
)
|
||||
|
||||
// Scanner reads new files into the library.
|
||||
type Scanner interface {
|
||||
ScanFiles(ctx context.Context, paths []string) ([]pgtype.UUID, error)
|
||||
}
|
||||
|
||||
// Catalogue lists a release group's editions.
|
||||
type Catalogue interface {
|
||||
Releases(ctx context.Context, groupMBID string) (catalogue.Editions, error)
|
||||
}
|
||||
|
||||
// Importer watches every live grab's download and imports it once finished.
|
||||
type Importer struct {
|
||||
pool *pgxpool.Pool
|
||||
Scanner Scanner
|
||||
Catalogue Catalogue
|
||||
Printer Printer
|
||||
// Roots are the library's folders (the scan paths). New music is filed
|
||||
// under the one the settings name, or the first.
|
||||
Roots []string
|
||||
// ClientFor builds the download client a grab went to. Defaults to the
|
||||
// acquisition_services row of that code.
|
||||
ClientFor func(ctx context.Context, code string) (downloader.Driver, error)
|
||||
logger *slog.Logger
|
||||
wake chan struct{}
|
||||
mu sync.Mutex // one pass at a time
|
||||
}
|
||||
|
||||
// New returns an Importer. printer may be nil: the check then rests on
|
||||
// lengths and tags.
|
||||
func New(pool *pgxpool.Pool, scanner Scanner, cat Catalogue, printer Printer, roots []string, logger *slog.Logger) *Importer {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
im := &Importer{pool: pool, Scanner: scanner, Catalogue: cat, Printer: printer, Roots: roots,
|
||||
logger: logger, wake: make(chan struct{}, 1)}
|
||||
im.ClientFor = im.clientFor
|
||||
return im
|
||||
}
|
||||
|
||||
func (im *Importer) clientFor(ctx context.Context, code string) (downloader.Driver, error) {
|
||||
row, err := dbq.New(im.pool).GetAcquisitionService(ctx, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("the download client %q is no longer set up", code)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return acquire.DownloadClient(row)
|
||||
}
|
||||
|
||||
// Wake asks for a pass now: after a grab, or a person's decision.
|
||||
func (im *Importer) Wake() {
|
||||
select {
|
||||
case im.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run passes every minute, and when woken, until ctx ends.
|
||||
func (im *Importer) Run(ctx context.Context) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
case <-im.wake:
|
||||
}
|
||||
rep := im.Pass(ctx)
|
||||
if rep.Imported+rep.Refused+rep.Review+rep.Held+rep.Failed > 0 || rep.Error != "" {
|
||||
im.logger.Info("import: pass", "imported", rep.Imported, "refused", rep.Refused,
|
||||
"review", rep.Review, "held", rep.Held, "failed", rep.Failed,
|
||||
"downloading", rep.Downloading, "error", rep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report is one pass.
|
||||
type Report struct {
|
||||
Downloading, Imported, Refused, Review, Held, Failed, Waiting int
|
||||
Error string
|
||||
}
|
||||
|
||||
// Pass looks at every live grab once.
|
||||
func (im *Importer) Pass(ctx context.Context) Report {
|
||||
im.mu.Lock()
|
||||
defer im.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, passTimeout)
|
||||
defer cancel()
|
||||
var rep Report
|
||||
q := dbq.New(im.pool)
|
||||
if _, err := q.ReclaimStaleImports(ctx, staleMinutes); err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
grabs, err := q.ListLiveGrabs(ctx)
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
byClient := map[string][]dbq.ListLiveGrabsRow{}
|
||||
var order []string
|
||||
for _, g := range grabs {
|
||||
if _, ok := byClient[g.Client]; !ok {
|
||||
order = append(order, g.Client)
|
||||
}
|
||||
byClient[g.Client] = append(byClient[g.Client], g)
|
||||
}
|
||||
for _, code := range order {
|
||||
im.passClient(ctx, q, code, byClient[code], &rep)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
func (im *Importer) passClient(ctx context.Context, q *dbq.Queries, code string, gs []dbq.ListLiveGrabsRow, rep *Report) {
|
||||
wait := func(g dbq.ListLiveGrabsRow, why string) {
|
||||
rep.Waiting++
|
||||
_ = q.SetGrabProgress(ctx, dbq.SetGrabProgressParams{
|
||||
ID: g.ID, Progress: g.Progress, ImportState: StateWaiting, ImportReason: why})
|
||||
}
|
||||
d, err := im.ClientFor(ctx, code)
|
||||
if err != nil {
|
||||
for _, g := range gs {
|
||||
wait(g, fmt.Sprintf("Can't reach its download client: %v.", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
hashes := make([]string, 0, len(gs))
|
||||
for _, g := range gs {
|
||||
hashes = append(hashes, g.InfoHash)
|
||||
}
|
||||
ts, err := d.Torrents(ctx, hashes...)
|
||||
if err != nil {
|
||||
for _, g := range gs {
|
||||
wait(g, fmt.Sprintf("Can't reach its download client %s: %s", code, indexer.FirstLine(err.Error())))
|
||||
}
|
||||
return
|
||||
}
|
||||
byHash := map[string]downloader.Torrent{}
|
||||
for _, t := range ts {
|
||||
byHash[indexer.NormalizeHash(t.Hash)] = t
|
||||
}
|
||||
for _, g := range gs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
switch g.ImportState {
|
||||
case StateReview, StateFailed, StateImporting:
|
||||
continue
|
||||
}
|
||||
t, ok := byHash[indexer.NormalizeHash(g.InfoHash)]
|
||||
if !ok {
|
||||
continue // gone from the client: the download lifecycle's (#5523)
|
||||
}
|
||||
if !g.WantID.Valid {
|
||||
wait(g, "Its album is no longer wanted.")
|
||||
continue
|
||||
}
|
||||
if !finished(ctx, d, t) {
|
||||
rep.Downloading++
|
||||
_ = q.SetGrabProgress(ctx, dbq.SetGrabProgressParams{
|
||||
ID: g.ID, Progress: float32(min(t.Progress, 0.999)), ImportState: StateDownloading})
|
||||
continue
|
||||
}
|
||||
claimed, err := q.ClaimGrabImport(ctx, g.ID)
|
||||
if err != nil {
|
||||
continue // claimed by a pass racing this one, or ended
|
||||
}
|
||||
switch out := im.importOne(ctx, q, claimed, d, t); out {
|
||||
case StateImported:
|
||||
rep.Imported++
|
||||
case StateRefused:
|
||||
rep.Refused++
|
||||
case StateReview:
|
||||
rep.Review++
|
||||
case StateHeld:
|
||||
rep.Held++
|
||||
case StateFailed:
|
||||
rep.Failed++
|
||||
case StateWaiting:
|
||||
rep.Waiting++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// upStates are qBittorrent's states for a torrent that has every byte and
|
||||
// is seeding, paused or queued to seed. Exchange's states arrive in the same
|
||||
// vocabulary.
|
||||
var upStates = map[string]bool{
|
||||
"uploading": true, "stalledUP": true, "pausedUP": true, "stoppedUP": true,
|
||||
"queuedUP": true, "forcedUP": true, "checkingUP": true,
|
||||
}
|
||||
|
||||
// finished: whole, in a seeding state, and no wanted file short of bytes.
|
||||
func finished(ctx context.Context, d downloader.Driver, t downloader.Torrent) bool {
|
||||
if !t.Done() || !upStates[t.State] {
|
||||
return false
|
||||
}
|
||||
if fp, ok := d.(downloader.FileProgress); ok {
|
||||
if states, err := fp.FileStates(ctx, t.Hash); err == nil {
|
||||
for _, f := range states {
|
||||
if f.Unfinished() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Detail is what grabs.import_detail keeps: the verdict and what it rested
|
||||
// on, the edition chosen, and what happened to the files.
|
||||
type Detail struct {
|
||||
Verdict Verdict `json:"verdict"`
|
||||
Edition string `json:"edition,omitempty"`
|
||||
EditionMBID string `json:"edition_mbid,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
Placed int `json:"placed,omitempty"`
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
Untagged int `json:"untagged,omitempty"`
|
||||
// RehomedFrom: the album it was grabbed for, when it turned out to be
|
||||
// another wanted album.
|
||||
RehomedFrom string `json:"rehomed_from,omitempty"`
|
||||
}
|
||||
|
||||
// importOne checks and files one finished download and records the outcome.
|
||||
func (im *Importer) importOne(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, t downloader.Torrent) string {
|
||||
record := func(state, reason, end string, det Detail, files int, dest string) string {
|
||||
body, _ := json.Marshal(det)
|
||||
// The outcome is written even when the pass's context is ending:
|
||||
// a claimed grab left "importing" waits half an hour to be retried.
|
||||
wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := q.RecordImport(wctx, dbq.RecordImportParams{
|
||||
ID: g.ID, ImportState: state, ImportReason: reason, ImportDetail: body,
|
||||
ImportedFiles: int32(files), Destination: dest, EndReason: end,
|
||||
}); err != nil {
|
||||
im.logger.Warn("import: record outcome", "grab", g.Name, "state", state, "err", err)
|
||||
}
|
||||
im.logger.Info("import: "+state, "grab", g.Name, "reason", reason)
|
||||
return state
|
||||
}
|
||||
|
||||
w, err := im.want(ctx, q, g.WantID)
|
||||
if err != nil {
|
||||
return record(StateWaiting, "Its album could not be read: "+err.Error(), "", Detail{}, 0, "")
|
||||
}
|
||||
editions, err := im.editions(ctx, w.GroupMBID)
|
||||
if err != nil {
|
||||
return record(StateWaiting, "MusicBrainz could not be reached to check it against the album; trying again shortly.",
|
||||
"", Detail{}, 0, "")
|
||||
}
|
||||
src := t.ContentPath
|
||||
if src == "" {
|
||||
src = filepath.Join(g.SavePath, t.Name)
|
||||
}
|
||||
files, printed, err := inspect(ctx, src, im.Printer)
|
||||
if err != nil {
|
||||
return record(StateFailed, fmt.Sprintf("Couldn't read the download at %s: %v", src, err), "", Detail{}, 0, "")
|
||||
}
|
||||
v := Verify(w, editions, files, printed, g.ImportForced)
|
||||
det := Detail{Verdict: v}
|
||||
for _, f := range files {
|
||||
det.Files = append(det.Files, f.Rel)
|
||||
}
|
||||
if v.Edition >= 0 {
|
||||
det.Edition, det.EditionMBID = describe(editions[v.Edition]), editions[v.Edition].MBID
|
||||
}
|
||||
|
||||
switch v.Outcome {
|
||||
case Review:
|
||||
return record(StateReview, v.Reason, "", det, 0, "")
|
||||
case Refuse:
|
||||
if v.Code == CodeWrongAlbum {
|
||||
if out, ok := im.rehome(ctx, q, g, d, w, files, printed, record); ok {
|
||||
return out
|
||||
}
|
||||
}
|
||||
im.refuse(ctx, q, g, d, w, v.Reason, pgtype.UUID{})
|
||||
return record(StateRefused, v.Reason, StateRefused, det, 0, "")
|
||||
}
|
||||
return im.place(ctx, q, g, d, w, editions, files, v, det, record)
|
||||
}
|
||||
|
||||
type recordFunc func(state, reason, end string, det Detail, files int, dest string) string
|
||||
|
||||
// place files an accepted download and records it imported, or held when
|
||||
// the library already had every track.
|
||||
func (im *Importer) place(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, w Want,
|
||||
editions []Edition, files []File, v Verdict, det Detail, record recordFunc) string {
|
||||
root, err := im.root(ctx, q)
|
||||
if err != nil {
|
||||
return record(StateFailed, "Not imported: "+err.Error()+".", "", det, 0, "")
|
||||
}
|
||||
var e *Edition
|
||||
if v.Edition >= 0 {
|
||||
e = &editions[v.Edition]
|
||||
}
|
||||
plan := Plan(root, w, e, files, v.Pairs)
|
||||
heldRec, heldPos, err := im.held(ctx, q, w, plan)
|
||||
if err != nil {
|
||||
return record(StateWaiting, "Couldn't read what the library holds: "+err.Error(), "", det, 0, "")
|
||||
}
|
||||
|
||||
var placed []string
|
||||
for _, pl := range plan {
|
||||
if (pl.Recording != "" && heldRec[pl.Recording]) || heldPos[[2]int{pl.Disc, pl.Track}] {
|
||||
det.Skipped++
|
||||
continue
|
||||
}
|
||||
tags := Tags{Title: pl.Title, Artist: w.Artist, Album: w.Title, AlbumArtist: w.Artist, Year: w.Year,
|
||||
Disc: pl.Disc, Discs: pl.Discs, Track: pl.Track, Tracks: pl.Tracks,
|
||||
GroupMBID: w.GroupMBID, RecordingMBID: pl.Recording, ArtistMBID: w.ArtistMBID}
|
||||
if e != nil {
|
||||
tags.ReleaseMBID = e.MBID
|
||||
}
|
||||
tagged, err := Place(ctx, files[pl.File].Path, pl.Dest, tags)
|
||||
if errors.Is(err, ErrOccupied) {
|
||||
det.Skipped++
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
for _, p := range placed {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
return record(StateFailed, fmt.Sprintf("Copying into the library failed, and nothing was left behind: %v", err),
|
||||
"", det, 0, "")
|
||||
}
|
||||
placed = append(placed, pl.Dest)
|
||||
if !tagged {
|
||||
det.Untagged++
|
||||
}
|
||||
}
|
||||
det.Placed = len(placed)
|
||||
if len(placed) == 0 {
|
||||
im.removeFromClient(ctx, d, g)
|
||||
return record(StateHeld, fmt.Sprintf("The library already holds every track of %s, so nothing was imported.", w.Title),
|
||||
StateHeld, det, 0, "")
|
||||
}
|
||||
|
||||
if _, err := im.Scanner.ScanFiles(ctx, placed); err != nil {
|
||||
// The watcher and the next scan find the files anyway.
|
||||
im.logger.Warn("import: scan the new files", "grab", g.Name, "err", err)
|
||||
}
|
||||
if err := q.MarkWantSearched(ctx, g.WantID); err != nil {
|
||||
im.logger.Warn("import: mark the want searched", "grab", g.Name, "err", err)
|
||||
}
|
||||
dest := filepath.Dir(placed[0])
|
||||
if e != nil && e.Media() > 1 {
|
||||
dest = filepath.Dir(dest)
|
||||
}
|
||||
reason := fmt.Sprintf("Imported %s into %s. %s", tracks(len(placed)), dest, v.Reason)
|
||||
if det.Skipped > 0 {
|
||||
reason += fmt.Sprintf(" %s already in the library left as they were.", tracks(det.Skipped))
|
||||
}
|
||||
if det.Untagged > 0 {
|
||||
reason += fmt.Sprintf(" %s copied with their own tags: ffmpeg could not rewrite them.", tracks(det.Untagged))
|
||||
}
|
||||
return record(StateImported, reason, StateImported, det, len(placed), dest)
|
||||
}
|
||||
|
||||
// rehome imports a download that turned out to be another wanted album,
|
||||
// named by its tags, for that album, when its own grab is not running.
|
||||
func (im *Importer) rehome(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver,
|
||||
from Want, files []File, printed bool, record recordFunc) (string, bool) {
|
||||
group := majority(files, func(f File) string { return f.GroupMBID })
|
||||
if group == "" {
|
||||
return "", false
|
||||
}
|
||||
others, err := q.OpenWantsForGroups(ctx, []string{group})
|
||||
if err != nil || len(others) == 0 {
|
||||
return "", false
|
||||
}
|
||||
other := others[0]
|
||||
w, err := im.want(ctx, q, other.ID)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
editions, err := im.editions(ctx, w.GroupMBID)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
v := Verify(w, editions, files, printed, false)
|
||||
if v.Outcome != Accept {
|
||||
return "", false
|
||||
}
|
||||
moved, err := q.RehomeGrab(ctx, dbq.RehomeGrabParams{ID: g.ID, WantID: other.ID})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
|
||||
im.logger.Warn("import: search the first album again", "want", from.Title, "err", err)
|
||||
}
|
||||
det := Detail{Verdict: v, RehomedFrom: from.Title}
|
||||
for _, f := range files {
|
||||
det.Files = append(det.Files, f.Rel)
|
||||
}
|
||||
if v.Edition >= 0 {
|
||||
det.Edition, det.EditionMBID = describe(editions[v.Edition]), editions[v.Edition].MBID
|
||||
}
|
||||
v.Reason = fmt.Sprintf("Grabbed for %s, it is %s, which is wanted too. %s", from.Title, w.Title, v.Reason)
|
||||
det.Verdict = v
|
||||
return im.place(ctx, q, moved, d, w, editions, files, v, det, record), true
|
||||
}
|
||||
|
||||
// refuse blocks the release, asks for the album to be searched again and
|
||||
// removes the download with its files: it is not the album.
|
||||
func (im *Importer) refuse(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, w Want, why string, by pgtype.UUID) {
|
||||
if err := acquire.NewMemory(im.pool).Block(ctx, dbq.BlockReleaseParams{
|
||||
InfoHash: g.InfoHash, Name: g.Name, Reason: acquire.BlockImport, Detail: why, WantID: g.WantID, BlockedBy: by,
|
||||
}); err != nil {
|
||||
im.logger.Warn("import: block the release", "grab", g.Name, "err", err)
|
||||
}
|
||||
if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
|
||||
im.logger.Warn("import: search again", "want", w.Title, "err", err)
|
||||
}
|
||||
im.removeFromClient(ctx, d, g)
|
||||
}
|
||||
|
||||
func (im *Importer) removeFromClient(ctx context.Context, d downloader.Driver, g dbq.Grab) {
|
||||
if d == nil || g.InfoHash == "" {
|
||||
return
|
||||
}
|
||||
if err := d.Delete(ctx, true, g.InfoHash); err != nil {
|
||||
im.logger.Warn("import: remove the download", "grab", g.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse is a person's "refuse" on a download the check held for them, or
|
||||
// one that failed: it is refused as the check would have.
|
||||
func (im *Importer) Refuse(ctx context.Context, grabID, by pgtype.UUID) (dbq.Grab, error) {
|
||||
q := dbq.New(im.pool)
|
||||
g, err := q.GetGrab(ctx, grabID)
|
||||
if err != nil {
|
||||
return dbq.Grab{}, err
|
||||
}
|
||||
if g.EndedAt.Valid || (g.ImportState != StateReview && g.ImportState != StateFailed) {
|
||||
return dbq.Grab{}, ErrNotDecidable
|
||||
}
|
||||
var w Want
|
||||
if g.WantID.Valid {
|
||||
w, _ = im.want(ctx, q, g.WantID)
|
||||
}
|
||||
d, err := im.ClientFor(ctx, g.Client)
|
||||
if err != nil {
|
||||
d = nil
|
||||
}
|
||||
why := "A person refused it: it is not the album."
|
||||
im.refuse(ctx, q, g, d, w, why, by)
|
||||
out, err := q.RecordImport(ctx, dbq.RecordImportParams{
|
||||
ID: g.ID, ImportState: StateRefused, ImportReason: why, ImportDetail: g.ImportDetail,
|
||||
EndReason: StateRefused,
|
||||
})
|
||||
im.Wake()
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ErrNotDecidable: the grab is not waiting on a person.
|
||||
var ErrNotDecidable = errors.New("importer: that download is not waiting on a decision")
|
||||
|
||||
func (im *Importer) want(ctx context.Context, q *dbq.Queries, id pgtype.UUID) (Want, error) {
|
||||
row, err := q.GetWantForSearch(ctx, id)
|
||||
if err != nil {
|
||||
return Want{}, err
|
||||
}
|
||||
return Want{
|
||||
GroupMBID: row.ReleaseGroupMbid, ArtistMBID: row.ArtistMbid, Artist: row.ArtistName, Title: row.Title,
|
||||
Year: yearOf(row.FirstReleaseDate), PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (im *Importer) editions(ctx context.Context, group string) ([]Edition, error) {
|
||||
if im.Catalogue == nil {
|
||||
return nil, errors.New("no catalogue")
|
||||
}
|
||||
eds, err := im.Catalogue.Releases(ctx, group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Edition, 0, len(eds.Editions))
|
||||
for _, e := range eds.Editions {
|
||||
ed := Edition{MBID: e.Release.Mbid, Title: e.Release.Title, Status: e.Release.Status,
|
||||
Date: e.Release.ReleaseDate, Country: e.Release.Country}
|
||||
for _, t := range e.Tracks {
|
||||
ed.Tracks = append(ed.Tracks, Track{Medium: int(t.MediumPosition), Format: t.MediumFormat,
|
||||
Position: int(t.Position), Title: t.Title, RecordingMBID: t.RecordingMbid, LengthMs: int(t.LengthMs)})
|
||||
}
|
||||
slices.SortStableFunc(ed.Tracks, func(a, b Track) int {
|
||||
if a.Medium != b.Medium {
|
||||
return a.Medium - b.Medium
|
||||
}
|
||||
return a.Position - b.Position
|
||||
})
|
||||
out = append(out, ed)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// root is the library folder new music is filed into.
|
||||
func (im *Importer) root(ctx context.Context, q *dbq.Queries) (string, error) {
|
||||
if len(im.Roots) == 0 {
|
||||
return "", errors.New("there is no library folder to file new music into (MINSTREL_LIBRARY_SCAN_PATHS)")
|
||||
}
|
||||
s, err := q.GetAcquisitionSettings(ctx)
|
||||
if err == nil && s.ImportRoot != "" && slices.Contains(im.Roots, s.ImportRoot) {
|
||||
return s.ImportRoot, nil
|
||||
}
|
||||
return im.Roots[0], nil
|
||||
}
|
||||
|
||||
// held reads which of the planned tracks the library already has: by
|
||||
// recording, and by disc and track of an album of the same group.
|
||||
func (im *Importer) held(ctx context.Context, q *dbq.Queries, w Want, plan []Placement) (map[string]bool, map[[2]int]bool, error) {
|
||||
var recs []string
|
||||
for _, pl := range plan {
|
||||
if pl.Recording != "" {
|
||||
recs = append(recs, pl.Recording)
|
||||
}
|
||||
}
|
||||
rec := map[string]bool{}
|
||||
if len(recs) > 0 {
|
||||
got, err := q.HeldRecordings(ctx, recs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, r := range got {
|
||||
rec[r] = true
|
||||
}
|
||||
}
|
||||
pos := map[[2]int]bool{}
|
||||
got, err := q.HeldPositions(ctx, &w.GroupMBID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, p := range got {
|
||||
pos[[2]int{int(p.Disc), int(p.Track)}] = true
|
||||
}
|
||||
return rec, pos, nil
|
||||
}
|
||||
|
||||
func yearOf(date string) int {
|
||||
if len(date) < 4 {
|
||||
return 0
|
||||
}
|
||||
y, err := strconv.Atoi(date[:4])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
func tracks(n int) string {
|
||||
if n == 1 {
|
||||
return "1 track"
|
||||
}
|
||||
return strconv.Itoa(n) + " tracks"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
const hash = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
// fakeClient is a download client holding one finished torrent.
|
||||
type fakeClient struct {
|
||||
downloader.Driver
|
||||
content string
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (c *fakeClient) Torrents(_ context.Context, _ ...string) ([]downloader.Torrent, error) {
|
||||
return []downloader.Torrent{{Hash: hash, Name: "Daft Punk - RAM [FLAC]", Progress: 1, State: "uploading",
|
||||
ContentPath: c.content}}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
|
||||
c.deleted = append(c.deleted, hashes...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeCatalogue knows RAM as one three-track edition.
|
||||
type fakeCatalogue struct{}
|
||||
|
||||
func (fakeCatalogue) Releases(_ context.Context, _ string) (catalogue.Editions, error) {
|
||||
return catalogue.Editions{Editions: []catalogue.Edition{{
|
||||
Release: dbq.MbRelease{Mbid: "rel-ram", Title: "Random Access Memories", Status: "Official", ReleaseDate: "2013-05-17"},
|
||||
Tracks: []dbq.MbReleaseTrack{
|
||||
{MediumPosition: 1, MediumFormat: "CD", Position: 1, Title: "Give Life Back to Music", RecordingMbid: "rec-01"},
|
||||
{MediumPosition: 1, MediumFormat: "CD", Position: 2, Title: "The Game of Love", RecordingMbid: "rec-02"},
|
||||
{MediumPosition: 1, MediumFormat: "CD", Position: 3, Title: "Giorgio by Moroder", RecordingMbid: "rec-03"},
|
||||
},
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
// fakePrinter names each file's recording by its file name: 01.flac is
|
||||
// rec-01, unless wrong is set.
|
||||
type fakePrinter struct{ wrong bool }
|
||||
|
||||
func (p fakePrinter) Recordings(_ context.Context, path string) ([]string, error) {
|
||||
if p.wrong {
|
||||
return []string{"rec-other"}, nil
|
||||
}
|
||||
return []string{"rec-" + strings.TrimSuffix(filepath.Base(path), ".flac")}, nil
|
||||
}
|
||||
|
||||
type fakeScanner struct{ scanned []string }
|
||||
|
||||
func (s *fakeScanner) ScanFiles(_ context.Context, paths []string) ([]pgtype.UUID, error) {
|
||||
s.scanned = append(s.scanned, paths...)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type stack struct {
|
||||
pool *pgxpool.Pool
|
||||
q *dbq.Queries
|
||||
im *Importer
|
||||
client *fakeClient
|
||||
scanner *fakeScanner
|
||||
library string
|
||||
want dbq.Want
|
||||
grab dbq.Grab
|
||||
}
|
||||
|
||||
// newStack wants RAM, has a finished download of it waiting in a fake
|
||||
// client, and an importer filing into a temp library.
|
||||
func newStack(t *testing.T, printer Printer) *stack {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
pool := newPool(t)
|
||||
q := dbq.New(pool)
|
||||
content := t.TempDir()
|
||||
for _, n := range []string{"01", "02", "03"} {
|
||||
if err := os.WriteFile(filepath.Join(content, n+".flac"), []byte("not really flac "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w, err := wanted.WantWith(ctx, q, wanted.WantParams{GroupMBID: "rg-ram", ArtistMBID: "mb-dp", ArtistName: "Daft Punk",
|
||||
Title: "Random Access Memories", PrimaryType: "Album", FirstReleaseDate: "2013-05-17", Reason: wanted.ReasonRequest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := acquire.NewMemory(pool).Grabbed(ctx, dbq.InsertGrabParams{WantID: w.ID, InfoHash: hash,
|
||||
Name: "Daft Punk - RAM [FLAC]", Key: hash, Client: "qbittorrent", SavePath: content, Origin: acquire.OriginSchedule},
|
||||
pgtype.UUID{}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{},
|
||||
library: t.TempDir(), want: w, grab: g}
|
||||
s.im = New(pool, s.scanner, fakeCatalogue{}, printer, []string{s.library}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return s.client, nil }
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *stack) grabNow(t *testing.T) dbq.Grab {
|
||||
t.Helper()
|
||||
g, err := s.q.GetGrab(context.Background(), s.grab.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestImport_AFinishedDownloadOfTheAlbumIsFiled_Integration(t *testing.T) {
|
||||
s := newStack(t, fakePrinter{})
|
||||
rep := s.im.Pass(context.Background())
|
||||
if rep.Imported != 1 {
|
||||
t.Fatalf("pass = %+v", rep)
|
||||
}
|
||||
g := s.grabNow(t)
|
||||
if g.ImportState != StateImported || g.EndReason != StateImported || !g.EndedAt.Valid || g.ImportedFiles != 3 {
|
||||
t.Fatalf("grab = %s %s ended=%v files=%d: %s", g.ImportState, g.EndReason, g.EndedAt.Valid, g.ImportedFiles, g.ImportReason)
|
||||
}
|
||||
dir := filepath.Join(s.library, "Daft Punk", "Random Access Memories (2013)")
|
||||
if g.Destination != dir {
|
||||
t.Errorf("destination %q", g.Destination)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "Daft Punk - Random Access Memories - 02 - The Game of Love.flac"))
|
||||
if err != nil || string(got) != "not really flac 02" {
|
||||
t.Errorf("filed copy: %q %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.client.content, "02.flac")); err != nil {
|
||||
t.Errorf("the download's own file went: %v", err)
|
||||
}
|
||||
if len(s.scanner.scanned) != 3 || len(s.client.deleted) != 0 {
|
||||
t.Errorf("scanned %v, deleted %v", s.scanner.scanned, s.client.deleted)
|
||||
}
|
||||
w, _ := s.q.GetWantByReleaseGroup(context.Background(), "rg-ram")
|
||||
if !w.SearchedAt.Valid {
|
||||
t.Error("an imported want is due for search again")
|
||||
}
|
||||
if s.im.Pass(context.Background()).Imported != 0 {
|
||||
t.Error("imported twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImport_TheWrongAlbumIsRefusedBlockedAndSearchedAgain_Integration(t *testing.T) {
|
||||
s := newStack(t, fakePrinter{wrong: true})
|
||||
ctx := context.Background()
|
||||
if err := s.q.MarkWantSearched(ctx, s.want.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep := s.im.Pass(ctx); rep.Refused != 1 {
|
||||
t.Fatalf("pass = %+v", rep)
|
||||
}
|
||||
g := s.grabNow(t)
|
||||
if g.ImportState != StateRefused || g.EndReason != StateRefused || !strings.Contains(g.ImportReason, "recordings") {
|
||||
t.Fatalf("grab = %s %s: %s", g.ImportState, g.EndReason, g.ImportReason)
|
||||
}
|
||||
blocks, err := s.q.ListReleaseBlocks(ctx)
|
||||
if err != nil || len(blocks) != 1 || blocks[0].InfoHash != hash || blocks[0].Reason != acquire.BlockImport {
|
||||
t.Errorf("blocks = %+v %v", blocks, err)
|
||||
}
|
||||
if len(s.client.deleted) != 1 {
|
||||
t.Errorf("the refused download was not removed: %v", s.client.deleted)
|
||||
}
|
||||
w, _ := s.q.GetWantByReleaseGroup(ctx, "rg-ram")
|
||||
if w.SearchedAt.Valid {
|
||||
t.Error("a refused want is not due for search")
|
||||
}
|
||||
entries, _ := os.ReadDir(s.library)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("the library got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImport_ADoubtfulDownloadWaitsForAPersonThenImportsAnyway_Integration(t *testing.T) {
|
||||
s := newStack(t, nil) // no AcoustID; untagged files: nothing but lengths
|
||||
ctx := context.Background()
|
||||
if rep := s.im.Pass(ctx); rep.Review != 1 {
|
||||
t.Fatalf("pass = %+v", rep)
|
||||
}
|
||||
g := s.grabNow(t)
|
||||
if g.ImportState != StateReview || g.EndedAt.Valid || !strings.Contains(g.ImportReason, "A person decides") {
|
||||
t.Fatalf("grab = %s ended=%v: %s", g.ImportState, g.EndedAt.Valid, g.ImportReason)
|
||||
}
|
||||
if rep := s.im.Pass(ctx); rep.Review+rep.Imported != 0 {
|
||||
t.Errorf("a held download was looked at again: %+v", rep)
|
||||
}
|
||||
if _, err := s.q.ForceGrabImport(ctx, g.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep := s.im.Pass(ctx); rep.Imported != 1 {
|
||||
t.Fatalf("after import anyway: %+v", rep)
|
||||
}
|
||||
if g := s.grabNow(t); g.ImportState != StateImported || !strings.Contains(g.ImportReason, "anyway") {
|
||||
t.Errorf("grab = %s: %s", g.ImportState, g.ImportReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImport_APersonRefusesADoubtfulDownload_Integration(t *testing.T) {
|
||||
s := newStack(t, nil)
|
||||
ctx := context.Background()
|
||||
s.im.Pass(ctx)
|
||||
g, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.ImportState != StateRefused || !g.EndedAt.Valid || len(s.client.deleted) != 1 {
|
||||
t.Errorf("grab = %s ended=%v deleted=%v", g.ImportState, g.EndedAt.Valid, s.client.deleted)
|
||||
}
|
||||
if _, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{}); err == nil {
|
||||
t.Error("refused twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImport_AnUnfinishedDownloadIsOnlyWatched_Integration(t *testing.T) {
|
||||
s := newStack(t, fakePrinter{})
|
||||
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return &halfClient{s.client}, nil }
|
||||
if rep := s.im.Pass(context.Background()); rep.Downloading != 1 {
|
||||
t.Fatalf("pass = %+v", rep)
|
||||
}
|
||||
g := s.grabNow(t)
|
||||
if g.ImportState != StateDownloading || g.Progress < 0.49 || g.Progress > 0.51 {
|
||||
t.Errorf("grab = %s %.2f", g.ImportState, g.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
type halfClient struct{ *fakeClient }
|
||||
|
||||
func (c *halfClient) Torrents(ctx context.Context, h ...string) ([]downloader.Torrent, error) {
|
||||
ts, _ := c.fakeClient.Torrents(ctx, h...)
|
||||
ts[0].Progress, ts[0].State = 0.5, "downloading"
|
||||
return ts, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Placement is where one file goes and the names written into its tags.
|
||||
type Placement struct {
|
||||
File int // index into the files
|
||||
Dest string // absolute
|
||||
Title string
|
||||
Disc int
|
||||
Discs int
|
||||
Track int
|
||||
Tracks int // on its disc
|
||||
// Recording is the MusicBrainz recording the file was matched to.
|
||||
Recording string
|
||||
}
|
||||
|
||||
// Plan names every file's destination under root the way Lidarr named the
|
||||
// library Minstrel took over (note #5527), so new arrivals sit beside what
|
||||
// is there:
|
||||
//
|
||||
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
|
||||
//
|
||||
// with a "{Medium Format} {medium:00}" folder between when the edition has
|
||||
// more than one medium. A file the check paired to a track takes the track's
|
||||
// name and place; one it did not (an "import anyway" with more files than
|
||||
// tracks) keeps its own tags.
|
||||
func Plan(root string, w Want, e *Edition, files []File, pairs []Pair) []Placement {
|
||||
album := w.Title
|
||||
if w.Year > 0 {
|
||||
album = fmt.Sprintf("%s (%d)", w.Title, w.Year)
|
||||
}
|
||||
dir := filepath.Join(root, Segment(w.Artist), Segment(album))
|
||||
media := 1
|
||||
if e != nil {
|
||||
media = max(e.Media(), 1)
|
||||
}
|
||||
perDisc := map[int]int{}
|
||||
if e != nil {
|
||||
for _, t := range e.Tracks {
|
||||
perDisc[t.Medium]++
|
||||
}
|
||||
}
|
||||
paired := map[int]Pair{}
|
||||
for _, p := range pairs {
|
||||
paired[p.File] = p
|
||||
}
|
||||
|
||||
out := make([]Placement, 0, len(files))
|
||||
for i, f := range files {
|
||||
pl := Placement{File: i, Title: f.Title, Disc: max(f.Disc, 1), Track: f.Track, Discs: media}
|
||||
format := ""
|
||||
if p, ok := paired[i]; ok && e != nil {
|
||||
t := e.Tracks[p.Track]
|
||||
pl.Title, pl.Disc, pl.Track, pl.Recording, format = t.Title, max(t.Medium, 1), t.Position, t.RecordingMBID, t.Format
|
||||
pl.Tracks = perDisc[t.Medium]
|
||||
}
|
||||
if pl.Title == "" {
|
||||
pl.Title = strings.TrimSuffix(filepath.Base(f.Path), filepath.Ext(f.Path))
|
||||
}
|
||||
d := dir
|
||||
if media > 1 {
|
||||
if format == "" {
|
||||
format = "Disc"
|
||||
}
|
||||
d = filepath.Join(dir, Segment(fmt.Sprintf("%s %02d", format, pl.Disc)))
|
||||
}
|
||||
name := fmt.Sprintf("%s - %s - %02d - %s", w.Artist, w.Title, pl.Track, pl.Title)
|
||||
pl.Dest = filepath.Join(d, Segment(name)+strings.ToLower(filepath.Ext(f.Path)))
|
||||
out = append(out, pl)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// segmentMax keeps a folder or file name inside the 255 bytes most
|
||||
// filesystems allow, with room for an extension and a temp prefix.
|
||||
const segmentMax = 200
|
||||
|
||||
var illegal = strings.NewReplacer(
|
||||
`\`, "+", "/", "+", "<", "", ">", "", "?", "!", "*", "-", "|", "", `"`, "",
|
||||
// Lidarr's default "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
|
||||
": ", " - ", ":", "-",
|
||||
)
|
||||
|
||||
// Segment makes a name safe as one path segment, the way Lidarr and Sonarr
|
||||
// clean file names: their illegal-character mapping, no leading or trailing
|
||||
// spaces or dots, at most segmentMax bytes, never "" or "..".
|
||||
func Segment(s string) string {
|
||||
s = illegal.Replace(s)
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
s = strings.Trim(s, " .")
|
||||
for len(s) > segmentMax {
|
||||
_, size := utf8.DecodeLastRuneInString(s)
|
||||
s = strings.TrimRight(s[:len(s)-size], " .")
|
||||
}
|
||||
if s == "" {
|
||||
return "_"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrOccupied: the destination already has a file. Nothing is overwritten.
|
||||
var ErrOccupied = errors.New("importer: destination already holds a file")
|
||||
|
||||
// tagTimeout bounds one ffmpeg run. A remux copies the audio untouched, so a
|
||||
// whole album track takes well under a second on local disk; two minutes is
|
||||
// for a slow network mount, not for ffmpeg.
|
||||
const tagTimeout = 2 * time.Minute
|
||||
|
||||
// Tags are the names written into an imported file. Empty values are left
|
||||
// out; the file's own tags stay for everything not named here.
|
||||
type Tags struct {
|
||||
Title, Artist, Album, AlbumArtist string
|
||||
Year int
|
||||
Disc, Discs, Track, Tracks int
|
||||
// MusicBrainz ids, written the way Picard writes them.
|
||||
ReleaseMBID, GroupMBID, RecordingMBID, ArtistMBID string
|
||||
}
|
||||
|
||||
// args turns the tags into ffmpeg -metadata pairs for a file of this
|
||||
// extension. Vorbis comments (FLAC, Ogg, Opus) take Picard's key names;
|
||||
// ID3 takes Picard's TXXX descriptions, which ffmpeg writes for any key it
|
||||
// has no frame for. MP4's freeform atoms ffmpeg cannot write, so an M4A
|
||||
// gets the plain names only.
|
||||
func (t Tags) args(ext string) []string {
|
||||
kv := [][2]string{
|
||||
{"title", t.Title}, {"artist", t.Artist}, {"album", t.Album}, {"album_artist", t.AlbumArtist},
|
||||
{"track", ofN(t.Track, t.Tracks)}, {"disc", ofN(t.Disc, t.Discs)},
|
||||
}
|
||||
if t.Year > 0 {
|
||||
kv = append(kv, [2]string{"date", strconv.Itoa(t.Year)})
|
||||
}
|
||||
switch ext {
|
||||
case ".flac", ".ogg", ".opus":
|
||||
kv = append(kv,
|
||||
[2]string{"MUSICBRAINZ_ALBUMID", t.ReleaseMBID},
|
||||
[2]string{"MUSICBRAINZ_RELEASEGROUPID", t.GroupMBID},
|
||||
[2]string{"MUSICBRAINZ_TRACKID", t.RecordingMBID},
|
||||
[2]string{"MUSICBRAINZ_ARTISTID", t.ArtistMBID},
|
||||
[2]string{"MUSICBRAINZ_ALBUMARTISTID", t.ArtistMBID})
|
||||
case ".mp3":
|
||||
kv = append(kv,
|
||||
[2]string{"MusicBrainz Album Id", t.ReleaseMBID},
|
||||
[2]string{"MusicBrainz Release Group Id", t.GroupMBID},
|
||||
[2]string{"MusicBrainz Track Id", t.RecordingMBID},
|
||||
[2]string{"MusicBrainz Artist Id", t.ArtistMBID},
|
||||
[2]string{"MusicBrainz Album Artist Id", t.ArtistMBID})
|
||||
}
|
||||
var out []string
|
||||
for _, p := range kv {
|
||||
if p[1] != "" {
|
||||
out = append(out, "-metadata", p[0]+"="+p[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ofN(n, of int) string {
|
||||
switch {
|
||||
case n <= 0:
|
||||
return ""
|
||||
case of > 0:
|
||||
return fmt.Sprintf("%d/%d", n, of)
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// taggable: the formats ffmpeg can rewrite tags in by remuxing.
|
||||
var taggable = map[string]bool{".flac": true, ".ogg": true, ".opus": true, ".mp3": true, ".m4a": true}
|
||||
|
||||
// Place copies src to dst with tags written, leaving src as it was (the
|
||||
// download keeps seeding). The copy is written to a dot-named temp file
|
||||
// beside dst, which the scanner skips, and renamed into place, so the
|
||||
// library never sees half a file. Tags are written by an ffmpeg remux that
|
||||
// copies the audio untouched; when ffmpeg cannot (it is missing, or the
|
||||
// container will not take the file's streams) the file is copied as it is
|
||||
// and tagged reports false. An existing dst is never overwritten.
|
||||
func Place(ctx context.Context, src, dst string, t Tags) (tagged bool, err error) {
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return false, fmt.Errorf("%w: %s", ErrOccupied, dst)
|
||||
}
|
||||
dir := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return false, fmt.Errorf("make %s: %w", dir, err)
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(dst))
|
||||
tmp := filepath.Join(dir, ".minstrel-import-"+randomHex()+ext)
|
||||
defer func() { _ = os.Remove(tmp) }() // gone already once renamed
|
||||
|
||||
if taggable[ext] {
|
||||
tagged = remux(ctx, src, tmp, t.args(ext)) == nil
|
||||
}
|
||||
if !tagged {
|
||||
if err := copyFile(src, tmp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o644); err != nil {
|
||||
return false, fmt.Errorf("chmod %s: %w", tmp, err)
|
||||
}
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return false, fmt.Errorf("%w: %s", ErrOccupied, dst)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return false, fmt.Errorf("move into place %s: %w", dst, err)
|
||||
}
|
||||
return tagged, nil
|
||||
}
|
||||
|
||||
// remux writes src to dst through ffmpeg with every stream copied as it is
|
||||
// (the cover art too) and the tags set. Its output is checked against the
|
||||
// source's size: a remux shifts container bytes, never the audio, so a file
|
||||
// much smaller than its source lost something on the way.
|
||||
func remux(ctx context.Context, src, dst string, meta []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, tagTimeout)
|
||||
defer cancel()
|
||||
args := append([]string{"-nostdin", "-v", "error", "-y", "-i", src,
|
||||
"-map", "0", "-c", "copy", "-map_metadata", "0"}, meta...)
|
||||
args = append(args, dst)
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
si, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
di, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if di.Size() < si.Size()/2 {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("ffmpeg wrote %d bytes from %d", di.Size(), si.Size())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies src to dst byte for byte and syncs it.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("copy %s: %w", src, err)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func randomHex() string {
|
||||
b := make([]byte, 6)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// Package importer checks a finished download against the album it was
|
||||
// grabbed for and files it into the library (M502 #5522). Nothing reaches
|
||||
// the library before the check says the files are the album: a wrong album,
|
||||
// another version of it, a video rip or an incomplete copy is refused, its
|
||||
// release is blocked and the album is searched for again.
|
||||
//
|
||||
// Ported from Fabled Librarian's importer and relocate packages, with the
|
||||
// check rewritten for music: the files are matched to one of the album's
|
||||
// MusicBrainz editions by AcoustID fingerprints, track lengths, titles and
|
||||
// the tags they carry.
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||
)
|
||||
|
||||
// Outcome is what the check decided.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
Accept Outcome = "accept"
|
||||
// Review: the files may be the album and the evidence cannot say. A
|
||||
// person decides.
|
||||
Review Outcome = "review"
|
||||
Refuse Outcome = "refuse"
|
||||
)
|
||||
|
||||
// Codes say why, beside the sentence in Verdict.Reason.
|
||||
const (
|
||||
CodeMatched = "matched"
|
||||
CodeForced = "forced"
|
||||
CodeNoAudio = "no_audio"
|
||||
CodeVideo = "video"
|
||||
CodeWrongAlbum = "wrong_album"
|
||||
CodeOtherVersion = release.VetoVersion
|
||||
CodeWrongTitle = release.VetoTitle
|
||||
CodeIncomplete = "incomplete"
|
||||
CodeNoEdition = "no_edition"
|
||||
CodeWeak = "weak"
|
||||
CodeTooMany = "too_many"
|
||||
)
|
||||
|
||||
// maxFiles is the most audio files one album is checked for. More is a box
|
||||
// set or a discography, which is a person's call, not a fingerprint queue.
|
||||
const maxFiles = 60
|
||||
|
||||
// File is what one audio file in a download says about itself.
|
||||
type File struct {
|
||||
Path string // absolute, as Minstrel sees it
|
||||
Rel string // under the download, for the person reading the verdict
|
||||
// Disc and Track are the tags' positions; 0 when untagged.
|
||||
Disc, Track int
|
||||
Title string
|
||||
Artist string
|
||||
AlbumArtist string
|
||||
Album string
|
||||
// MusicBrainz ids from the tags, when a tagger wrote them.
|
||||
GroupMBID string
|
||||
ReleaseMBID string
|
||||
RecordingMBID string
|
||||
LengthMs int
|
||||
// Recordings are the MusicBrainz recordings AcoustID says the audio is,
|
||||
// at or above the operator's minimum score.
|
||||
Recordings []string
|
||||
// Video: the file name says it is a music video, a lyric video or the
|
||||
// like (library.SourceMarkersFor).
|
||||
Video bool
|
||||
}
|
||||
|
||||
// Track is one track of an edition.
|
||||
type Track struct {
|
||||
Medium int
|
||||
Format string // the medium's: CD, Digital Media, Vinyl
|
||||
Position int
|
||||
Title string
|
||||
RecordingMBID string
|
||||
LengthMs int
|
||||
}
|
||||
|
||||
// Edition is one MusicBrainz release of the wanted release group, its
|
||||
// tracks in medium and position order.
|
||||
type Edition struct {
|
||||
MBID string
|
||||
Title string
|
||||
Status string
|
||||
Date string
|
||||
Country string
|
||||
Tracks []Track
|
||||
}
|
||||
|
||||
// Media is how many media the edition has.
|
||||
func (e Edition) Media() int {
|
||||
n := 0
|
||||
for _, t := range e.Tracks {
|
||||
n = max(n, t.Medium)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Want is the album the download was grabbed for.
|
||||
type Want struct {
|
||||
GroupMBID string
|
||||
ArtistMBID string
|
||||
Artist string
|
||||
Title string
|
||||
Year int
|
||||
PrimaryType string
|
||||
SecondaryTypes []string
|
||||
}
|
||||
|
||||
// Pair is a file matched to an edition's track, and what agreed.
|
||||
type Pair struct {
|
||||
File int `json:"file"`
|
||||
Track int `json:"track"`
|
||||
Recording bool `json:"recording"`
|
||||
Length bool `json:"length"`
|
||||
Title bool `json:"title"`
|
||||
}
|
||||
|
||||
// Evidence is what the check counted, kept with the verdict so a person can
|
||||
// see what it rested on.
|
||||
type Evidence struct {
|
||||
Files int `json:"files"`
|
||||
Tracks int `json:"tracks"`
|
||||
// Identified: files with a recording from AcoustID or their tags.
|
||||
Identified int `json:"identified"`
|
||||
Recordings int `json:"recordings"`
|
||||
Lengths int `json:"lengths"`
|
||||
Titles int `json:"titles"`
|
||||
// GroupTag: "same" when the tags name this release group, "other" when
|
||||
// another, "" when they name none.
|
||||
GroupTag string `json:"group_tag,omitempty"`
|
||||
AlbumTag string `json:"album_tag,omitempty"`
|
||||
// Fingerprinted: AcoustID was asked. False when it is not set up or
|
||||
// could not be reached; the check then rests on lengths and tags.
|
||||
Fingerprinted bool `json:"fingerprinted"`
|
||||
}
|
||||
|
||||
// Verdict is the check's answer.
|
||||
type Verdict struct {
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Code string `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
// Edition indexes the editions passed in; -1 when none was chosen.
|
||||
Edition int `json:"-"`
|
||||
Pairs []Pair `json:"pairs,omitempty"`
|
||||
Evidence Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// Verify decides whether files are the wanted album and, if so, which
|
||||
// edition and which track each file is. force is a person's "import anyway":
|
||||
// the best edition is taken whatever the evidence, but a download with no
|
||||
// audio is still refused.
|
||||
func Verify(w Want, editions []Edition, files []File, fingerprinted, force bool) Verdict {
|
||||
v := Verdict{Edition: -1, Evidence: Evidence{Files: len(files), Fingerprinted: fingerprinted}}
|
||||
if len(files) == 0 {
|
||||
return v.set(Refuse, CodeNoAudio, "The download holds no audio files.")
|
||||
}
|
||||
if !force && len(files) > maxFiles {
|
||||
return v.set(Review, CodeTooMany, fmt.Sprintf(
|
||||
"The download holds %d audio files, more than one album; a person decides.", len(files)))
|
||||
}
|
||||
if video := countIf(files, func(f File) bool { return f.Video }); !force && video*2 > len(files) {
|
||||
return v.set(Refuse, CodeVideo, fmt.Sprintf(
|
||||
"%d of its %d audio files are named as music or lyric videos: the sound of a video, not the album.", video, len(files)))
|
||||
}
|
||||
|
||||
group := majority(files, func(f File) string { return f.GroupMBID })
|
||||
switch {
|
||||
case group == "":
|
||||
case strings.EqualFold(group, w.GroupMBID):
|
||||
v.Evidence.GroupTag = "same"
|
||||
default:
|
||||
v.Evidence.GroupTag = "other"
|
||||
}
|
||||
album := majority(files, func(f File) string { return f.Album })
|
||||
v.Evidence.AlbumTag = album
|
||||
var tagVeto release.Match
|
||||
albumAgrees := false
|
||||
if album != "" {
|
||||
// The want's own artist goes in front: the tags' artist spelling is
|
||||
// not what this asks. Only the album title and its version words.
|
||||
m := release.Judge(release.Want{Artist: w.Artist, Title: w.Title, PrimaryType: w.PrimaryType,
|
||||
SecondaryTypes: w.SecondaryTypes}, release.Parse(w.Artist+" - "+album))
|
||||
switch m.Veto {
|
||||
case release.VetoVersion, release.VetoTitle:
|
||||
tagVeto = m
|
||||
case "":
|
||||
albumAgrees = true
|
||||
}
|
||||
}
|
||||
if !force && v.Evidence.GroupTag == "other" {
|
||||
return v.set(Refuse, CodeWrongAlbum, fmt.Sprintf(
|
||||
"Its tags name a different MusicBrainz release group (%s), not %s.", group, w.Title))
|
||||
}
|
||||
|
||||
if len(editions) == 0 {
|
||||
if force {
|
||||
v.Code, v.Outcome, v.Reason = CodeForced, Accept, "Imported anyway at a person's word; MusicBrainz lists no edition to match it to."
|
||||
return v
|
||||
}
|
||||
return v.set(Review, CodeNoEdition, fmt.Sprintf(
|
||||
"MusicBrainz lists no edition of %s to check the files against; a person decides.", w.Title))
|
||||
}
|
||||
|
||||
best, bestScore := -1, -1
|
||||
shortest, longest := len(editions[0].Tracks), 0
|
||||
for i, e := range editions {
|
||||
shortest, longest = min(shortest, len(e.Tracks)), max(longest, len(e.Tracks))
|
||||
if len(e.Tracks) != len(files) && !force {
|
||||
continue
|
||||
}
|
||||
pairs := pairUp(files, e.Tracks)
|
||||
if s := score(pairs); s > bestScore || (s == bestScore && better(e, editions[best])) {
|
||||
best, bestScore = i, s
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
if len(files) < shortest {
|
||||
return v.set(Refuse, CodeIncomplete, fmt.Sprintf(
|
||||
"It holds %d audio files; the shortest edition of %s has %d tracks.", len(files), w.Title, shortest))
|
||||
}
|
||||
return v.set(Review, CodeNoEdition, fmt.Sprintf(
|
||||
"It holds %d audio files; no edition of %s has %d tracks (they have %s). A person decides.",
|
||||
len(files), w.Title, len(files), span(shortest, longest)))
|
||||
}
|
||||
|
||||
e := editions[best]
|
||||
v.Edition = best
|
||||
v.Pairs = pairUp(files, e.Tracks)
|
||||
n := len(e.Tracks)
|
||||
ev := &v.Evidence
|
||||
ev.Tracks = n
|
||||
for _, p := range v.Pairs {
|
||||
f := files[p.File]
|
||||
if f.RecordingMBID != "" || len(f.Recordings) > 0 {
|
||||
ev.Identified++
|
||||
}
|
||||
ev.Recordings += b2i(p.Recording)
|
||||
ev.Lengths += b2i(p.Length)
|
||||
ev.Titles += b2i(p.Title)
|
||||
}
|
||||
if force {
|
||||
v.Outcome, v.Code = Accept, CodeForced
|
||||
v.Reason = fmt.Sprintf("Imported anyway at a person's word, as %s. %s", describe(e), ev.sentence())
|
||||
return v
|
||||
}
|
||||
|
||||
half := (n + 1) / 2
|
||||
strong := ev.GroupTag == "same" || (ev.Identified >= half && ev.Recordings*10 >= ev.Identified*7)
|
||||
switch {
|
||||
case ev.Identified >= half && ev.Recordings*10 < ev.Identified*3 && ev.GroupTag != "same":
|
||||
return v.set(Refuse, CodeWrongAlbum, fmt.Sprintf(
|
||||
"Only %d of the %d tracks AcoustID knows are recordings on %s.", ev.Recordings, ev.Identified, w.Title))
|
||||
case tagVeto.Veto != "" && !strong:
|
||||
return v.set(Refuse, tagVeto.Veto, fmt.Sprintf("Its tags call it %q. %s", album, tagVeto.Reason))
|
||||
case !strong && ev.Identified == 0 && ev.Lengths*2 < n:
|
||||
return v.set(Refuse, CodeWrongAlbum, fmt.Sprintf(
|
||||
"Only %d of its %d tracks are the length of %s's, and nothing else says it is the album.", ev.Lengths, n, w.Title))
|
||||
case strong && ev.Lengths*10 >= n*6,
|
||||
!strong && ev.Lengths*10 >= n*8 && (ev.Titles*10 >= n*6 || albumAgrees):
|
||||
v.Outcome, v.Code = Accept, CodeMatched
|
||||
v.Reason = fmt.Sprintf("It is %s. %s", describe(e), ev.sentence())
|
||||
return v
|
||||
}
|
||||
return v.set(Review, CodeWeak, fmt.Sprintf(
|
||||
"It may be %s, but the evidence is thin: %s A person decides.", describe(e), ev.sentence()))
|
||||
}
|
||||
|
||||
func (v Verdict) set(o Outcome, code, reason string) Verdict {
|
||||
v.Outcome, v.Code, v.Reason = o, code, reason
|
||||
return v
|
||||
}
|
||||
|
||||
// sentence says what agreed, for the verdict's reason.
|
||||
func (ev Evidence) sentence() string {
|
||||
var parts []string
|
||||
if ev.GroupTag == "same" {
|
||||
parts = append(parts, "its tags name this album")
|
||||
}
|
||||
if ev.Identified > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d of %d identified tracks are its recordings", ev.Recordings, ev.Identified))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d of %d lengths fit", ev.Lengths, ev.Tracks),
|
||||
fmt.Sprintf("%d of %d titles agree", ev.Titles, ev.Tracks))
|
||||
s := strings.Join(parts, ", ")
|
||||
s = strings.ToUpper(s[:1]) + s[1:] + "."
|
||||
if !ev.Fingerprinted {
|
||||
s += " AcoustID was not asked (not set up or unreachable), so lengths and tags decided."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// describe names an edition for a person: "the 2013 CD release (13 tracks)".
|
||||
func describe(e Edition) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("the ")
|
||||
if len(e.Date) >= 4 {
|
||||
b.WriteString(e.Date[:4] + " ")
|
||||
}
|
||||
if f := e.format(); f != "" {
|
||||
b.WriteString(f + " ")
|
||||
}
|
||||
fmt.Fprintf(&b, "edition (%d tracks)", len(e.Tracks))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e Edition) format() string {
|
||||
if len(e.Tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
f := e.Tracks[0].Format
|
||||
for _, t := range e.Tracks {
|
||||
if t.Format != f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// pairUp matches files to tracks in order: by the tags' disc and track
|
||||
// numbers when every file has them and no two share a place, by file path
|
||||
// otherwise. With a person's "import anyway" the counts may differ; pairs
|
||||
// run to the shorter.
|
||||
func pairUp(files []File, tracks []Track) []Pair {
|
||||
order := make([]int, len(files))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
if numbered(files) {
|
||||
sort.SliceStable(order, func(a, b int) bool {
|
||||
fa, fb := files[order[a]], files[order[b]]
|
||||
if da, db := max(fa.Disc, 1), max(fb.Disc, 1); da != db {
|
||||
return da < db
|
||||
}
|
||||
return fa.Track < fb.Track
|
||||
})
|
||||
} else {
|
||||
sort.SliceStable(order, func(a, b int) bool { return files[order[a]].Rel < files[order[b]].Rel })
|
||||
}
|
||||
n := min(len(files), len(tracks))
|
||||
pairs := make([]Pair, n)
|
||||
for i := range n {
|
||||
f, t := files[order[i]], tracks[i]
|
||||
pairs[i] = Pair{
|
||||
File: order[i],
|
||||
Track: i,
|
||||
Recording: t.RecordingMBID != "" && (f.RecordingMBID == t.RecordingMBID || slices.Contains(f.Recordings, t.RecordingMBID)),
|
||||
Length: lengthFits(f.LengthMs, t.LengthMs),
|
||||
Title: titleAgrees(f.Title, t.Title),
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// numbered: every file carries a track number and no two share a disc and
|
||||
// track.
|
||||
func numbered(files []File) bool {
|
||||
seen := map[[2]int]bool{}
|
||||
for _, f := range files {
|
||||
k := [2]int{max(f.Disc, 1), f.Track}
|
||||
if f.Track <= 0 || seen[k] {
|
||||
return false
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// lengthFits allows five seconds or 3%, whichever is more: remasters and
|
||||
// rips differ by a second or two, a different recording by far more. A
|
||||
// track MusicBrainz has no length for says nothing against the file.
|
||||
func lengthFits(fileMs, trackMs int) bool {
|
||||
if trackMs <= 0 {
|
||||
return true
|
||||
}
|
||||
if fileMs <= 0 {
|
||||
return false
|
||||
}
|
||||
tol := max(5000, trackMs*3/100)
|
||||
d := fileMs - trackMs
|
||||
return d <= tol && -d <= tol
|
||||
}
|
||||
|
||||
// titleAgrees: the same words, or one title holding all of the other's
|
||||
// ("Get Lucky (Radio Edit)" holds "Get Lucky").
|
||||
func titleAgrees(a, b string) bool {
|
||||
ta, tb := release.Tokens(a), release.Tokens(b)
|
||||
if len(ta) == 0 || len(tb) == 0 {
|
||||
return false
|
||||
}
|
||||
return subset(ta, tb) || subset(tb, ta)
|
||||
}
|
||||
|
||||
func subset(small, big []string) bool {
|
||||
for _, w := range small {
|
||||
if !slices.Contains(big, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// score weighs recordings above lengths above titles: a fingerprint is the
|
||||
// audio itself, a length is the audio's shape, a title is only a name.
|
||||
func score(pairs []Pair) int {
|
||||
s := 0
|
||||
for _, p := range pairs {
|
||||
s += 3*b2i(p.Recording) + 2*b2i(p.Length) + b2i(p.Title)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// better breaks a tie between equally good editions: an official one, then
|
||||
// the earlier.
|
||||
func better(a, b Edition) bool {
|
||||
if (a.Status == "Official") != (b.Status == "Official") {
|
||||
return a.Status == "Official"
|
||||
}
|
||||
return a.Date != "" && (b.Date == "" || a.Date < b.Date)
|
||||
}
|
||||
|
||||
func majority(files []File, get func(File) string) string {
|
||||
counts := map[string]int{}
|
||||
best, n := "", 0
|
||||
for _, f := range files {
|
||||
v := strings.TrimSpace(get(f))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
counts[strings.ToLower(v)]++
|
||||
if c := counts[strings.ToLower(v)]; c > n {
|
||||
best, n = v, c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func countIf(files []File, f func(File) bool) int {
|
||||
n := 0
|
||||
for _, x := range files {
|
||||
if f(x) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func span(a, b int) string {
|
||||
if a == b {
|
||||
return fmt.Sprint(a)
|
||||
}
|
||||
return fmt.Sprintf("%d to %d", a, b)
|
||||
}
|
||||
|
||||
func b2i(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var ram = Want{GroupMBID: "rg-ram", ArtistMBID: "mb-dp", Artist: "Daft Punk", Title: "Random Access Memories",
|
||||
Year: 2013, PrimaryType: "Album"}
|
||||
|
||||
var ramTitles = []string{"Give Life Back to Music", "The Game of Love", "Giorgio by Moroder", "Within",
|
||||
"Instant Crush", "Lose Yourself to Dance", "Touch", "Get Lucky", "Beyond", "Motherboard",
|
||||
"Fragments of Time", "Doin' It Right", "Contact"}
|
||||
|
||||
// edition is RAM's 13-track CD, recordings rec-01.., lengths 4:00 + n seconds.
|
||||
func edition(mbid string, n int) Edition {
|
||||
e := Edition{MBID: mbid, Title: "Random Access Memories", Status: "Official", Date: "2013-05-17"}
|
||||
for i := range n {
|
||||
e.Tracks = append(e.Tracks, Track{Medium: 1, Format: "CD", Position: i + 1, Title: ramTitles[i%len(ramTitles)],
|
||||
RecordingMBID: fmt.Sprintf("rec-%02d", i+1), LengthMs: 240000 + i*1000})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// download is n files lined up with the edition: tagged positions, lengths
|
||||
// within a second, AcoustID naming each file's recording.
|
||||
func download(n int) []File {
|
||||
var fs []File
|
||||
for i := range n {
|
||||
fs = append(fs, File{Path: fmt.Sprintf("/dl/%02d.flac", i+1), Rel: fmt.Sprintf("%02d.flac", i+1),
|
||||
Disc: 1, Track: i + 1, Title: ramTitles[i%len(ramTitles)], Album: "Random Access Memories",
|
||||
LengthMs: 240000 + i*1000 + 400, Recordings: []string{fmt.Sprintf("rec-%02d", i+1)}})
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func TestVerify_TheAlbumAndWhatIsNot(t *testing.T) {
|
||||
eds := []Edition{edition("rel-cd", 13), edition("rel-deluxe", 15)}
|
||||
cases := []struct {
|
||||
name string
|
||||
files func() []File
|
||||
printed bool
|
||||
out Outcome
|
||||
code string
|
||||
edition string
|
||||
}{
|
||||
{"the album, fingerprinted", func() []File { return download(13) }, true, Accept, CodeMatched, "rel-cd"},
|
||||
{"the deluxe by count", func() []File { return download(15) }, true, Accept, CodeMatched, "rel-deluxe"},
|
||||
{"no audio", func() []File { return nil }, true, Refuse, CodeNoAudio, ""},
|
||||
{"music videos", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Video = i < 10
|
||||
}
|
||||
return fs
|
||||
}, true, Refuse, CodeVideo, ""},
|
||||
{"tags name another group", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].GroupMBID = "rg-homework"
|
||||
}
|
||||
return fs
|
||||
}, true, Refuse, CodeWrongAlbum, ""},
|
||||
{"fingerprints say other recordings", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings = []string{"rec-other"}
|
||||
}
|
||||
return fs
|
||||
}, true, Refuse, CodeWrongAlbum, "rel-cd"},
|
||||
{"the drumless edition: right lengths, album tag says otherwise", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings, fs[i].Album = nil, "Random Access Memories (Drumless Edition)"
|
||||
}
|
||||
return fs
|
||||
}, false, Refuse, CodeOtherVersion, "rel-cd"},
|
||||
{"fewer files than any edition", func() []File { return download(9) }, true, Refuse, CodeIncomplete, ""},
|
||||
{"no edition this long", func() []File { return download(14) }, true, Review, CodeNoEdition, ""},
|
||||
{"no fingerprints, lengths and titles agree", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings = nil
|
||||
}
|
||||
return fs
|
||||
}, false, Accept, CodeMatched, "rel-cd"},
|
||||
{"no fingerprints, no titles, nothing to go on but lengths", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings, fs[i].Title, fs[i].Album = nil, "", ""
|
||||
}
|
||||
return fs
|
||||
}, false, Review, CodeWeak, "rel-cd"},
|
||||
{"no fingerprints, lengths all wrong", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings, fs[i].LengthMs = nil, 600000
|
||||
}
|
||||
return fs
|
||||
}, false, Refuse, CodeWrongAlbum, "rel-cd"},
|
||||
{"tags name the group: strong even unprinted", func() []File {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings, fs[i].Title, fs[i].GroupMBID = nil, "", "rg-ram"
|
||||
}
|
||||
return fs
|
||||
}, false, Accept, CodeMatched, "rel-cd"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
v := Verify(ram, eds, c.files(), c.printed, false)
|
||||
if v.Outcome != c.out || v.Code != c.code {
|
||||
t.Fatalf("got %s/%s (%s), want %s/%s", v.Outcome, v.Code, v.Reason, c.out, c.code)
|
||||
}
|
||||
got := ""
|
||||
if v.Edition >= 0 {
|
||||
got = eds[v.Edition].MBID
|
||||
}
|
||||
if got != c.edition {
|
||||
t.Errorf("edition %q, want %q", got, c.edition)
|
||||
}
|
||||
if v.Reason == "" {
|
||||
t.Error("no reason given")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_UnnumberedFilesPairByPath(t *testing.T) {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Track, fs[i].Disc = 0, 0
|
||||
}
|
||||
fs[0], fs[5] = fs[5], fs[0] // listed out of order; their names sort right
|
||||
v := Verify(ram, []Edition{edition("rel-cd", 13)}, fs, true, false)
|
||||
if v.Outcome != Accept || v.Evidence.Recordings != 13 {
|
||||
t.Fatalf("got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_ImportAnywayTakesTheBestEdition(t *testing.T) {
|
||||
v := Verify(ram, []Edition{edition("rel-cd", 13)}, download(14), true, true)
|
||||
if v.Outcome != Accept || v.Code != CodeForced || v.Edition != 0 || len(v.Pairs) != 13 {
|
||||
t.Fatalf("got %+v", v)
|
||||
}
|
||||
if v := Verify(ram, nil, nil, true, true); v.Outcome != Refuse {
|
||||
t.Errorf("forced with no audio: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) {
|
||||
fs := download(13)
|
||||
for i := range fs {
|
||||
fs[i].Recordings = nil
|
||||
}
|
||||
v := Verify(ram, []Edition{edition("rel-cd", 13)}, fs, false, false)
|
||||
if !strings.Contains(v.Reason, "AcoustID was not asked") {
|
||||
t.Errorf("reason %q", v.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLengthFits(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
file, track int
|
||||
ok bool
|
||||
}{
|
||||
{240000, 244000, true}, {240000, 246000, false}, {600000, 615000, true}, {600000, 620000, false},
|
||||
{0, 240000, false}, {240000, 0, true},
|
||||
} {
|
||||
if got := lengthFits(c.file, c.track); got != c.ok {
|
||||
t.Errorf("lengthFits(%d, %d) = %v", c.file, c.track, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_LidarrsNaming(t *testing.T) {
|
||||
e := edition("rel-cd", 2)
|
||||
e.Tracks[1].Title = "Within: Part 2/3?"
|
||||
pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs)
|
||||
want := []string{
|
||||
"/music/Daft Punk/Random Access Memories (2013)/Daft Punk - Random Access Memories - 01 - Give Life Back to Music.flac",
|
||||
"/music/Daft Punk/Random Access Memories (2013)/Daft Punk - Random Access Memories - 02 - Within - Part 2+3!.flac",
|
||||
}
|
||||
for i, p := range pl {
|
||||
if p.Dest != want[i] {
|
||||
t.Errorf("dest %d = %q, want %q", i, p.Dest, want[i])
|
||||
}
|
||||
}
|
||||
if pl[0].Recording != "rec-01" || pl[0].Tracks != 2 {
|
||||
t.Errorf("placement %+v", pl[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_MultiDiscGetsAMediumFolder(t *testing.T) {
|
||||
e := edition("rel-2cd", 4)
|
||||
e.Tracks[2].Medium, e.Tracks[2].Position = 2, 1
|
||||
e.Tracks[3].Medium, e.Tracks[3].Position = 2, 2
|
||||
fs := download(4)
|
||||
fs[2].Disc, fs[2].Track, fs[3].Disc, fs[3].Track = 2, 1, 2, 2
|
||||
pl := Plan("/music", ram, &e, fs, Verify(ram, []Edition{e}, fs, true, false).Pairs)
|
||||
if got := filepath.Dir(pl[3].Dest); got != "/music/Daft Punk/Random Access Memories (2013)/CD 02" {
|
||||
t.Errorf("disc folder %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(pl[3].Dest, " - 02 - Get Lucky.flac") && !strings.Contains(pl[3].Dest, " - 02 - ") {
|
||||
t.Errorf("dest %q", pl[3].Dest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegment(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"AC/DC": "AC+DC", `What?`: "What!", "Title: Sub": "Title - Sub", "a:b": "a-b", " ..x.. ": "x",
|
||||
"": "_", "..": "_", `"Quoted" <x> *y* |z|`: "Quoted x -y- z",
|
||||
} {
|
||||
if got := Segment(in); got != want {
|
||||
t.Errorf("Segment(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if long := Segment(strings.Repeat("é", 200)); len(long) > segmentMax {
|
||||
t.Errorf("long segment is %d bytes", len(long))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagsArgs(t *testing.T) {
|
||||
tg := Tags{Title: "Get Lucky", Artist: "Daft Punk", Track: 8, Tracks: 13, Disc: 1, Discs: 1, Year: 2013,
|
||||
RecordingMBID: "rec-08", GroupMBID: "rg-ram"}
|
||||
flac := strings.Join(tg.args(".flac"), " ")
|
||||
for _, s := range []string{"title=Get Lucky", "track=8/13", "date=2013", "MUSICBRAINZ_TRACKID=rec-08", "MUSICBRAINZ_RELEASEGROUPID=rg-ram"} {
|
||||
if !strings.Contains(flac, s) {
|
||||
t.Errorf("flac args lack %q: %s", s, flac)
|
||||
}
|
||||
}
|
||||
if strings.Contains(flac, "MUSICBRAINZ_ALBUMID") {
|
||||
t.Errorf("an empty release id was written: %s", flac)
|
||||
}
|
||||
if mp3 := strings.Join(tg.args(".mp3"), " "); !strings.Contains(mp3, "MusicBrainz Track Id=rec-08") {
|
||||
t.Errorf("mp3 args %s", mp3)
|
||||
}
|
||||
if m4a := strings.Join(tg.args(".m4a"), " "); strings.Contains(m4a, "MusicBrainz") {
|
||||
t.Errorf("m4a args %s", m4a)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user