Files
bvandeusenandClaude Opus 5.5 ca57133c8a
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m18s
release / integration (push) Successful in 5m23s
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 21s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a release group whose title names a live, remix or demo version is wanted only when the profile allows that version (M502 #5573)
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC
Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now
reads the title's qualifier (brackets, or after a dash) for the version it
names and treats it as a secondary type; a version no type names, an
acoustic or instrumental one, no profile passes. The follow check takes off
the list an open follow want the rule now refuses. Judge counts bbc,
sessions and unplugged as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:39:35 -04:00

211 lines
6.2 KiB
Go
Raw Permalink 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", "bbc", "sessions", "unplugged"}, "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
}