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>
211 lines
6.1 KiB
Go
211 lines
6.1 KiB
Go
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
|
||
}
|