feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
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

- 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>
This commit is contained in:
2026-10-10 08:56:47 -04:00
co-authored by Claude Opus 5.5
parent 1a1646a920
commit 89b3b44779
40 changed files with 5332 additions and 49 deletions
+210
View File
@@ -0,0 +1,210 @@
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
}