Files
minstrel/internal/release/match.go
T
bvandeusenandClaude Opus 5.5 89b3b44779
release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
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
feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
- internal/release: reads a release name (format, bitrate, bit depth and
  sample rate, source, year, video, discography) and judges it against the
  album wanted: the artist and title must be in it; video rips,
  discographies, other versions (drumless, instrumental, karaoke, remix,
  live unless the album is live…) and singles or EPs for an album are
  refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read
  for format, video files and a track count short of the album.
- internal/acquire: fan-out across every search source with a narrowed
  search kept, a process-wide pacer per source, every candidate judged and
  kept with its reasons, and the grab: one live grab per want, tie-broken
  (a person's grab beats the schedule's; a better release replaces a worse
  one unless it is nearly done). Each download lands in its own folder
  under the client's downloads folder.
- internal/wanted: the search schedule, every ten minutes, never-searched
  first, then requests, missing files and followed artists, taking the
  best release when auto-grab is on (the default).
- Migration 0082: acquisition_settings (auto-grab, interval, allowed
  qualities seeded from Lidarr's "Any"), want search columns,
  want_candidates, grabs, release_blocks.
- Web: the Wanted page shows each want's last search, a Search button and
  every release found with its verdict (Grab, Grab anyway, Never this one,
  Allow again). Integrations gains "Searching and grabbing".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:56:47 -04:00

211 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package release
import "strings"
// Want is the album a search is for, as the catalogue describes it.
type Want struct {
Artist string
Title string
Year int
PrimaryType string // Album, EP, Single, Broadcast, Other
SecondaryTypes []string
// MinTracks and MaxTracks span the group's editions' track counts; zero
// when the catalogue has not read them.
MinTracks int
MaxTracks int
}
// Veto codes: why a name is not the album. Kept on a candidate as its
// reason's code.
const (
VetoArtist = "wrong_artist"
VetoTitle = "wrong_title"
VetoVideo = "video"
VetoDiscography = "discography"
VetoVersion = "other_version"
VetoType = "wrong_type"
VetoNoAudio = "no_audio"
VetoIncomplete = "incomplete"
)
// Match is what a name says about whether it is the album wanted.
type Match struct {
// Veto is a code from above when the name is not the album, with Reason
// saying why in a sentence.
Veto string
Reason string
// Doubt is set when the name may be the album and cannot say for sure: a
// person decides. It is a sentence.
Doubt string
// YearAgrees: the name states the album's year, give or take one.
YearAgrees bool
}
// versions are words that make a release a different recording of the
// album, with what to call it. A word the wanted title or artist holds does
// not count ("Live Through This", "Instrumental Core").
var versions = []struct {
words []string
what string
// typ is the MusicBrainz secondary type that wants this version.
typ string
}{
{[]string{"instrumental", "instrumentals"}, "an instrumental version", ""},
{[]string{"drumless"}, "a drumless edition", ""},
{[]string{"karaoke"}, "a karaoke version", ""},
{[]string{"acapella", "acappella", "accapella", "acapellas"}, "an a cappella version", ""},
{[]string{"remix", "remixes", "remixed"}, "a remix release", "Remix"},
{[]string{"live", "bootleg"}, "a live recording", "Live"},
{[]string{"demo", "demos"}, "demo recordings", "Demo"},
// Not "covers": in a release name that is usually the artwork scans.
{[]string{"tribute", "lullaby", "lullabies"}, "a tribute release", ""},
{[]string{"nightcore", "slowed", "sped"}, "a slowed or sped-up edit", ""},
{[]string{"commentary", "interview", "interviews"}, "commentary or interviews", "Interview"},
}
// collections are words naming more than one album.
var collections = []string{"collection", "anthology", "albums", "boxset", "discography"}
// Judge says whether a release name is the album wanted. It looks only at
// the name; JudgeFiles looks at what a torrent holds.
func Judge(w Want, r Release) Match {
var m Match
m.YearAgrees = r.Year != 0 && w.Year != 0 && abs(r.Year-w.Year) <= 1
if r.Video != "" {
m.Veto, m.Reason = VetoVideo, "A video, not audio (“"+r.Video+"”)."
return m
}
artist := Tokens(w.Artist)
title := Tokens(w.Title)
if len(artist) > 0 && !contains(r.Tokens, artist) && !contains(r.Tokens, withoutThe(artist)) {
m.Veto, m.Reason = VetoArtist, "The name doesn’t mention "+w.Artist+"."
return m
}
if len(title) > 0 && !contains(r.Tokens, title) && !contains(r.Tokens, bare(w.Title)) {
m.Veto, m.Reason = VetoTitle, "The name doesn’t contain the title “"+w.Title+"”."
return m
}
own := map[string]bool{}
for _, t := range append(append([]string{}, title...), artist...) {
own[t] = true
}
if r.Discography != "" && !own[r.Discography] {
m.Veto, m.Reason = VetoDiscography, "A discography or collection ("+r.Discography+"), and one album is wanted."
return m
}
for _, c := range collections {
if !own[c] && has(r.Tokens, c) {
m.Veto, m.Reason = VetoDiscography, "A collection of albums (“"+c+"”), and one album is wanted."
return m
}
}
for _, v := range versions {
if v.typ != "" && hasType(w.SecondaryTypes, v.typ) {
continue
}
for _, word := range v.words {
if !own[word] && has(r.Tokens, word) {
m.Veto, m.Reason = VetoVersion, "This is "+v.what+" (“"+word+"”)."
return m
}
}
}
if w.PrimaryType == "Album" || w.PrimaryType == "EP" {
if !own["single"] && has(r.Tokens, "single") {
m.Veto, m.Reason = VetoType, "A single, and the "+strings.ToLower(w.PrimaryType)+" is wanted."
return m
}
}
if w.PrimaryType == "Album" && !own["ep"] && has(r.Tokens, "ep") {
m.Veto, m.Reason = VetoType, "An EP, and the album is wanted."
return m
}
// A self-titled album: every release of the artist names it once. Two
// mentions, or its year, say it is this one.
if len(title) > 0 && equal(title, artist) && count(r.Tokens, title) < 2 && !m.YearAgrees {
m.Doubt = "The album shares the artist’s name, and nothing else in the name says it is this album."
}
return m
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func withoutThe(t []string) []string {
if len(t) > 1 && t[0] == "the" {
return t[1:]
}
return t
}
// bare is a title without a trailing bracketed part ("Album (Remastered)"),
// for names that leave it off.
func bare(title string) []string {
if i := strings.IndexAny(title, "(["); i > 0 {
return Tokens(title[:i])
}
return nil
}
// contains says whether want's words appear in name in order and together,
// or, for two or more words, run together ("ACDC" for AC/DC). A single word
// is never looked for run together: "muse" is inside "museum".
func contains(name, want []string) bool {
if len(want) == 0 {
return false
}
if count(name, want) > 0 {
return true
}
joined := strings.Join(want, "")
if len(want) < 2 || len(joined) < 4 {
return false
}
return strings.Contains(strings.Join(name, ""), joined)
}
// count is how often want's words appear in name in order and together.
func count(name, want []string) int {
n := 0
for i := 0; i+len(want) <= len(name); i++ {
if equal(name[i:i+len(want)], want) {
n++
}
}
return n
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func has(tokens []string, word string) bool {
for _, t := range tokens {
if t == word {
return true
}
}
return false
}
func hasType(types []string, t string) bool {
for _, x := range types {
if strings.EqualFold(x, t) {
return true
}
}
return false
}