release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's cutoff, note #5527; "" is off), an "Upgrade until" row under Searching. - The wanted worker judges held albums hourly: tier per format from size per second (the scanner records no bitrate), the album's worst. Below the cutoff it is wanted for quality; a video rip among its tracks always. - The schedule searches upgrades weekly, after open wants; a release no better than the copy held is refused ("not_better"). - The import matches each file to the track it would replace (recording, then disc/track) and replaces it when it is a rip or the file is a better tier: over the same path in one rename, or filed beside it and the old track folded in (library.ReplaceTrack: file first, then foldTrackInto). Nothing better: the release is set aside. - Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a clean copy was looked for instead of Lidarr's search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
13 KiB
Go
440 lines
13 KiB
Go
// 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"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"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
|
|
// Upgrade: a held album wanted for a better copy (#5524), quality or
|
|
// rip; AlbumID is the library album it holds, whose tracks the import
|
|
// replaces.
|
|
Upgrade string
|
|
AlbumID pgtype.UUID
|
|
}
|
|
|
|
// 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: release.LengthFits(f.LengthMs, t.LengthMs),
|
|
Title: release.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
|
|
}
|
|
|
|
// 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
|
|
}
|