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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user