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
+76
View File
@@ -0,0 +1,76 @@
package release
import (
"path"
"strconv"
"strings"
)
// File is one file inside a torrent.
type File struct {
Index int
Path string
Size int64
}
// Contents is what a torrent's file list holds.
type Contents struct {
Audio int
Video int
// Format is the format most of the audio files are in, "" when none.
Format string
}
var videoExt = map[string]bool{
"mkv": true, "mp4": true, "avi": true, "m2ts": true, "vob": true,
"mov": true, "webm": true, "wmv": true, "m4v": true, "ts": true,
}
// Inspect counts a file list's audio and video files.
func Inspect(files []File) Contents {
var c Contents
formats := map[string]int{}
for _, f := range files {
ext := strings.TrimPrefix(strings.ToLower(path.Ext(f.Path)), ".")
if fm := formatOf(ext); fm != "" {
c.Audio++
formats[fm]++
} else if videoExt[ext] {
c.Video++
}
}
best := 0
for fm, n := range formats {
if n > best || (n == best && formatRank[fm] > formatRank[c.Format]) {
c.Format, best = fm, n
}
}
return c
}
// JudgeFiles says what a torrent's files say about whether it is the album:
// a veto (code and sentence), or a doubt for a person, or nothing.
func JudgeFiles(w Want, files []File) (veto, reason, doubt string) {
if len(files) == 0 {
return "", "", ""
}
c := Inspect(files)
switch {
case c.Audio == 0 && c.Video > 0:
return VetoVideo, "It holds video files and no audio.", ""
case c.Audio == 0:
return VetoNoAudio, "It holds no audio files Minstrel can read.", ""
case w.MinTracks > 0 && c.Audio < w.MinTracks:
return VetoIncomplete, "It holds " + tracks(c.Audio) + "; the shortest edition has " + tracks(w.MinTracks) + ".", ""
case w.MaxTracks > 0 && c.Audio > 2*w.MaxTracks+4:
return "", "", "It holds " + tracks(c.Audio) + ", more than any edition of the album has: probably more than one album."
}
return "", "", ""
}
func tracks(n int) string {
if n == 1 {
return "1 track"
}
return strconv.Itoa(n) + " tracks"
}
+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
}
+289
View File
@@ -0,0 +1,289 @@
// Package release reads music release names and judges them against a
// wanted album (M502 #5521): what a name says it is (format, bitrate, bit
// depth, source, year, edition), whether it is the album wanted, and which
// quality tier it sits in. Nothing here touches the network or the database,
// so every rule is testable on a string.
package release
import (
"regexp"
"strconv"
"strings"
)
// Formats a name can state.
const (
FormatFLAC = "FLAC"
FormatALAC = "ALAC"
FormatAPE = "APE"
FormatWV = "WavPack"
FormatWAV = "WAV"
FormatAIFF = "AIFF"
FormatDSD = "DSD"
FormatMP3 = "MP3"
FormatAAC = "AAC"
FormatVorbis = "Vorbis"
FormatOpus = "Opus"
)
// Sources a name can state.
const (
SourceWEB = "WEB"
SourceCD = "CD"
SourceVinyl = "Vinyl"
SourceSACD = "SACD"
SourceCassette = "Cassette"
)
// Release is what a release name says about itself. Zero values mean the
// name does not say.
type Release struct {
Name string
Year int
Format string
// Bitrate is a constant bitrate in kbps; VBR is a LAME preset (V0, V1, V2).
Bitrate int
VBR string
// BitDepth (16, 24) and SampleRate (kHz) for lossless.
BitDepth int
SampleRate float64
Source string
// Video is the marker that says this is a video ("x264", "1080p"), or "".
Video string
// Discography: a year range or a word naming many albums.
Discography string
// Tokens are the name's words, normalised for matching.
Tokens []string
}
var (
formatRe = regexp.MustCompile(`\b(flac|alac|ape|wavpack|wv|wav|aiff|dsd|dsf|dff|mp3|aac|m4a|ogg|vorbis|opus)\b`)
// "24-96", "24/192", "16-44 1" (44.1 after dots become spaces).
depthRateRe = regexp.MustCompile(`\b(16|24|32) ?[-/ ] ?(44 1|44|48|88 2|88|96|176 4|176|192|352 8|384)\b`)
depthRe = regexp.MustCompile(`\b(16|24|32) ?-?bits?\b`)
rateRe = regexp.MustCompile(`\b(44 1|48|88 2|96|176 4|192) ?khz\b`)
hiResRe = regexp.MustCompile(`\bhi ?-?res\b`)
codecRateRe = regexp.MustCompile(`\b(?:mp3|aac|m4a|cbr|ogg|opus)[ \-@]*(320|256|224|192|160|128|112|96|64)\b`)
kbpsRe = regexp.MustCompile(`\b(320|256|224|192|160|128|112|96|64) ?(?:kbps|kbit|kb/s|k)\b`)
vbrRe = regexp.MustCompile(`\bv([0-2])\b`)
webRe = regexp.MustCompile(`\bweb(?: ?-?dl)?\b`)
cdRe = regexp.MustCompile(`\bcd ?(?:rip)?\b`)
vinylRe = regexp.MustCompile(`\b(?:vinyl|vinylrip|lp ?rip|needle ?drop)\b`)
sacdRe = regexp.MustCompile(`\bsacd\b`)
cassetteRe = regexp.MustCompile(`\b(?:cassette|tape ?rip)\b`)
yearRe = regexp.MustCompile(`\b(19[3-9]\d|20[0-4]\d)\b`)
bracketYear = regexp.MustCompile(`[(\[{] ?(19[3-9]\d|20[0-4]\d) ?[)\]}]`)
yearRangeRe = regexp.MustCompile(`\b(19[3-9]\d|20[0-4]\d) ?(?:-|–|to) ?(19[3-9]\d|20[0-4]\d)\b`)
videoRe = regexp.MustCompile(`\b(x264|x265|h ?264|h ?265|hevc|xvid|divx|1080p|1080i|720p|2160p|bluray|blu ?ray|bdrip|brrip|bdremux|dvdrip|dvd9|dvd5|webrip|hdtv|mkv|avi|m2ts)\b`)
discogRe = regexp.MustCompile(`\b(discography|discografia|discographie|diskografie|discografie)\b`)
)
// Parse reads what a release name says.
func Parse(name string) Release {
r := Release{Name: name}
s := spaced(name)
r.Format = bestFormat(formatRe.FindAllStringSubmatch(s, -1))
if m := depthRateRe.FindStringSubmatch(s); m != nil {
r.BitDepth, _ = strconv.Atoi(m[1])
r.SampleRate = rate(m[2])
}
if r.BitDepth == 0 {
if m := depthRe.FindStringSubmatch(s); m != nil {
r.BitDepth, _ = strconv.Atoi(m[1])
}
}
if r.SampleRate == 0 {
if m := rateRe.FindStringSubmatch(s); m != nil {
r.SampleRate = rate(m[1])
}
}
if r.BitDepth == 0 && hiResRe.MatchString(s) {
r.BitDepth = 24
}
if m := codecRateRe.FindStringSubmatch(s); m != nil {
r.Bitrate, _ = strconv.Atoi(m[1])
} else if m := kbpsRe.FindStringSubmatch(s); m != nil {
r.Bitrate, _ = strconv.Atoi(m[1])
}
// A bare "V0" is a LAME preset only beside MP3 or VBR; elsewhere it can
// be part of a title.
if m := vbrRe.FindStringSubmatch(s); m != nil && (r.Format == FormatMP3 || strings.Contains(s, "vbr")) {
r.VBR = "V" + m[1]
}
if r.Format == "" && (r.Bitrate > 0 || r.VBR != "") {
r.Format = FormatMP3
}
switch {
case sacdRe.MatchString(s):
r.Source = SourceSACD
case vinylRe.MatchString(s):
r.Source = SourceVinyl
case webRe.MatchString(s):
r.Source = SourceWEB
case cassetteRe.MatchString(s):
r.Source = SourceCassette
case cdRe.MatchString(s):
r.Source = SourceCD
}
if m := videoRe.FindStringSubmatch(s); m != nil {
r.Video = m[1]
}
if m := discogRe.FindStringSubmatch(s); m != nil {
r.Discography = m[1]
} else if m := yearRangeRe.FindStringSubmatch(s); m != nil && m[2] > m[1] {
r.Discography = m[1] + "–" + m[2]
}
r.Year = year(s)
r.Tokens = Tokens(name)
return r
}
// spaced lowercases a name and turns its separators into spaces, keeping
// the slash in "24/96" and "kb/s".
func spaced(name string) string {
var b strings.Builder
for _, c := range strings.ToLower(name) {
switch c {
case '_', '.', ',':
b.WriteByte(' ')
default:
b.WriteRune(c)
}
}
return b.String()
}
// rate reads a sample rate in kHz; "44", "88" and "176" are the short
// spellings of 44.1, 88.2 and 176.4.
func rate(s string) float64 {
switch s {
case "44":
return 44.1
case "88":
return 88.2
case "176":
return 176.4
}
f, _ := strconv.ParseFloat(strings.ReplaceAll(s, " ", "."), 64)
return f
}
// formatRank orders formats a name might state together ("FLAC + MP3"):
// the best is the one it is described by.
var formatRank = map[string]int{
FormatDSD: 11, FormatFLAC: 10, FormatALAC: 9, FormatWV: 8, FormatAPE: 7, FormatWAV: 6, FormatAIFF: 5,
FormatOpus: 4, FormatAAC: 3, FormatVorbis: 2, FormatMP3: 1,
}
func bestFormat(found [][]string) string {
best := ""
for _, m := range found {
f := formatOf(m[1])
if formatRank[f] > formatRank[best] {
best = f
}
}
return best
}
// formatOf names the format a word or file extension stands for.
func formatOf(word string) string {
switch word {
case "flac":
return FormatFLAC
case "alac":
return FormatALAC
case "ape":
return FormatAPE
case "wavpack", "wv":
return FormatWV
case "wav":
return FormatWAV
case "aiff", "aif":
return FormatAIFF
case "dsd", "dsf", "dff":
return FormatDSD
case "mp3":
return FormatMP3
case "aac", "m4a":
return FormatAAC
case "ogg", "vorbis":
return FormatVorbis
case "opus":
return FormatOpus
}
return ""
}
// year is the release year a name states: one in brackets when there is
// one (so the title "1989" is not read as its year), else the last.
func year(s string) int {
if m := bracketYear.FindStringSubmatch(s); m != nil {
y, _ := strconv.Atoi(m[1])
return y
}
all := yearRe.FindAllString(s, -1)
if len(all) == 0 {
return 0
}
y, _ := strconv.Atoi(all[len(all)-1])
return y
}
// fold maps the accented letters music names carry to plain ones.
var fold = map[rune]string{
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ā': "a", 'ą': "a",
'æ': "ae", 'ç': "c", 'ć': "c", 'č': "c",
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ē': "e", 'ė': "e", 'ę': "e", 'ě': "e",
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i", 'ı': "i",
'ñ': "n", 'ń': "n", 'ň': "n",
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o", 'ő': "o",
'œ': "oe", 'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u", 'ů': "u", 'ű': "u",
'ý': "y", 'ÿ': "y", 'ß': "ss", 'š': "s", 'ś': "s", 'ş': "s",
'ž': "z", 'ź': "z", 'ż': "z", 'ł': "l", 'đ': "d", 'ð': "d", 'þ': "th", 'ř': "r",
}
// abbreviations are spelled out on both sides before comparing.
var abbreviations = map[string]string{"vol": "volume", "pt": "part", "n": "and"}
// Tokens are a name's words for matching: lowercase, accents folded, "&"
// and "+" read as "and", apostrophes dropped (so "Don't" is "dont"), every
// other mark a word break.
func Tokens(s string) []string {
var b strings.Builder
for _, c := range strings.ToLower(s) {
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
b.WriteRune(c)
case c == '\'' || c == '’' || c == '`' || c == '´':
case c == '&' || c == '+':
b.WriteString(" and ")
default:
if f, ok := fold[c]; ok {
b.WriteString(f)
} else if c > 127 && isLetter(c) {
b.WriteRune(c)
} else {
b.WriteByte(' ')
}
}
}
words := strings.Fields(b.String())
for i, w := range words {
if full, ok := abbreviations[w]; ok {
words[i] = full
}
}
return words
}
// isLetter keeps letters of scripts the fold table does not cover (Cyrillic,
// CJK) as letters rather than breaks.
func isLetter(c rune) bool {
return (c >= 0x0400 && c <= 0x04FF) || (c >= 0x0370 && c <= 0x03FF) || c >= 0x3040
}
+169
View File
@@ -0,0 +1,169 @@
package release
import (
"fmt"
"strconv"
"strings"
)
// Tier is a rung of the quality ladder, Lidarr's names for them.
type Tier string
// The ladder, best first.
const (
TierLosslessHiRes Tier = "lossless_hires"
TierLossless Tier = "lossless"
TierLossyHigh Tier = "lossy_high"
TierLossyStandard Tier = "lossy_standard"
TierLossyLow Tier = "lossy_low"
TierLossyPoor Tier = "lossy_poor"
TierLossyTrash Tier = "lossy_trash"
TierUnknown Tier = "unknown"
)
// losslessCategory is Newznab's Audio/Lossless.
const losslessCategory = 3040
// TierInfo is a rung as the settings page shows it.
type TierInfo struct {
Tier Tier `json:"tier"`
Label string `json:"label"`
What string `json:"what"`
}
// Tiers is the ladder, best first.
var Tiers = []TierInfo{
{TierLosslessHiRes, "Lossless, hi-res", "24-bit or above 48 kHz: FLAC, ALAC, DSD"},
{TierLossless, "Lossless", "FLAC, ALAC, APE, WavPack, WAV at CD quality"},
{TierLossyHigh, "High quality lossy", "MP3 320 or V0, AAC 256 and up"},
{TierLossyStandard, "Standard quality lossy", "MP3 224–256 or V1/V2, AAC 192, Vorbis, Opus, or a lossy format with no bitrate stated"},
{TierLossyLow, "Low quality lossy", "160–192 kbps"},
{TierLossyPoor, "Poor quality lossy", "128 kbps"},
{TierLossyTrash, "Trash quality lossy", "Under 128 kbps"},
{TierUnknown, "Unknown", "Nothing says what format it is"},
}
// DefaultTiers is what a new install allows: Lidarr's "Any" profile, every
// format from trash lossy to lossless (note #5527). Unknown is left out; a
// release that names no format has its .torrent read to find out.
var DefaultTiers = []Tier{
TierLosslessHiRes, TierLossless, TierLossyHigh, TierLossyStandard,
TierLossyLow, TierLossyPoor, TierLossyTrash,
}
// KnownTier says whether t is a rung of the ladder.
func KnownTier(t string) bool {
for _, info := range Tiers {
if string(info.Tier) == t {
return true
}
}
return false
}
// Rank orders tiers: higher is better, Unknown lowest.
func Rank(t Tier) int {
for i, info := range Tiers {
if info.Tier == t {
return len(Tiers) - i
}
}
return 0
}
// Label is a tier's name for a person.
func Label(t Tier) string {
for _, info := range Tiers {
if info.Tier == t {
return info.Label
}
}
return string(t)
}
// TierOf places a release on the ladder. category is the indexer's; 3040
// (Lossless) stands in for a name that states no format.
func TierOf(r Release, category int) Tier {
switch r.Format {
case FormatDSD:
return TierLosslessHiRes
case FormatFLAC, FormatALAC, FormatAPE, FormatWV, FormatWAV, FormatAIFF:
if r.BitDepth >= 24 || r.SampleRate > 48 {
return TierLosslessHiRes
}
return TierLossless
case FormatMP3:
switch r.VBR {
case "V0":
return TierLossyHigh
case "V1", "V2":
return TierLossyStandard
}
return byBitrate(r.Bitrate, 320)
case FormatAAC, FormatVorbis, FormatOpus:
return byBitrate(r.Bitrate, 256)
}
if category == losslessCategory {
return TierLossless
}
return TierUnknown
}
// byBitrate places a lossy bitrate; high is the bitrate from which the codec
// counts as high quality. An unstated bitrate is standard.
func byBitrate(kbps, high int) Tier {
switch {
case kbps == 0:
return TierLossyStandard
case kbps >= high:
return TierLossyHigh
case kbps >= 224 || (high == 256 && kbps >= 192):
return TierLossyStandard
case kbps >= 160:
return TierLossyLow
case kbps >= 128:
return TierLossyPoor
}
return TierLossyTrash
}
// Describe is a release's format in a few words: "FLAC 24-bit/96 kHz",
// "MP3 V0", "AAC 256". Empty when the name states none.
func Describe(r Release) string {
if r.Format == "" {
return ""
}
parts := []string{r.Format}
switch {
case r.BitDepth > 0 && r.SampleRate > 0:
parts = append(parts, fmt.Sprintf("%d-bit/%s kHz", r.BitDepth, khz(r.SampleRate)))
case r.BitDepth > 0:
parts = append(parts, fmt.Sprintf("%d-bit", r.BitDepth))
case r.VBR != "":
parts = append(parts, r.VBR)
case r.Bitrate > 0:
parts = append(parts, strconv.Itoa(r.Bitrate))
}
return strings.Join(parts, " ")
}
func khz(f float64) string {
return strings.TrimSuffix(strconv.FormatFloat(f, 'f', 1, 64), ".0")
}
// Profile is which tiers the operator takes.
type Profile struct {
Allowed map[Tier]bool
}
// NewProfile builds a profile from the stored tier list.
func NewProfile(tiers []string) Profile {
p := Profile{Allowed: map[Tier]bool{}}
for _, t := range tiers {
p.Allowed[Tier(t)] = true
}
return p
}
// Allows says whether the profile takes a tier.
func (p Profile) Allows(t Tier) bool { return p.Allowed[t] }
+251
View File
@@ -0,0 +1,251 @@
package release
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"strings"
"testing"
)
func TestParse_ReadsWhatTheNameSays(t *testing.T) {
for _, c := range []struct {
name string
format, vbr, src string
bitrate, depth, yr int
rate float64
tier Tier
}{
// The wrong edition Lidarr grabbed on 2026-10-09 (note #5527).
{"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", FormatFLAC, "", "", 0, 24, 2023, 88.2, TierLosslessHiRes},
{"Gorillaz - Humanz - (Japanese Edition) - [CD-FLAC] 2017", FormatFLAC, "", SourceCD, 0, 0, 2017, 0, TierLossless},
{"Radiohead - OK Computer (1997) [MP3 320]", FormatMP3, "", "", 320, 0, 1997, 0, TierLossyHigh},
{"Artist - Album 2019 MP3 V0", FormatMP3, "V0", "", 0, 0, 2019, 0, TierLossyHigh},
{"Artist - Album (2020) [WEB] [AAC 256]", FormatAAC, "", SourceWEB, 256, 0, 2020, 0, TierLossyHigh},
{"Artist_-_Album-(2004)-192kbps", FormatMP3, "", "", 192, 0, 2004, 0, TierLossyLow},
{"Artist - Album [24bit-96kHz] FLAC Vinyl", FormatFLAC, "", SourceVinyl, 0, 24, 0, 96, TierLosslessHiRes},
{"Taylor Swift - 1989 (2014) [FLAC]", FormatFLAC, "", "", 0, 0, 2014, 0, TierLossless},
{"Artist - Album", "", "", "", 0, 0, 0, 0, TierUnknown},
} {
r := Parse(c.name)
got := fmt.Sprint(r.Format, r.VBR, r.Source, r.Bitrate, r.BitDepth, r.Year, r.SampleRate, TierOf(r, 0))
want := fmt.Sprint(c.format, c.vbr, c.src, c.bitrate, c.depth, c.yr, c.rate, c.tier)
if got != want {
t.Errorf("Parse(%q)\n got %s\nwant %s", c.name, got, want)
}
}
}
func TestParse_VideoAndDiscographies(t *testing.T) {
if v := Parse("Artist.Album.2018.1080p.BluRay.x264").Video; v != "1080p" {
t.Errorf("video = %q", v)
}
if d := Parse("Artist Discography 1994-2010 FLAC").Discography; d != "discography" {
t.Errorf("discography = %q", d)
}
if d := Parse("Artist - Studio Albums 1994 - 2010 [MP3]").Discography; d != "1994–2010" {
t.Errorf("year range = %q", d)
}
if d := Parse("Artist - Album (2010) [FLAC]").Discography; d != "" {
t.Errorf("one year read as a range: %q", d)
}
}
func judge(w Want, name string) Match { return Judge(w, Parse(name)) }
func TestJudge_TheAlbumAndWhatIsNot(t *testing.T) {
ram := Want{Artist: "Daft Punk", Title: "Random Access Memories", Year: 2013, PrimaryType: "Album"}
for name, veto := range map[string]string{
"Daft Punk - Random Access Memories (2013) [FLAC]": "",
"Daft_Punk-Random_Access_Memories-WEB-2013": "",
"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]": VetoVersion,
"Daft Punk - Random Access Memories (Instrumentals) [MP3]": VetoVersion,
"Daft Punk - Random Access Memories Remixes": VetoVersion,
"Daft Punk - Random Access Memories (Live) FLAC": VetoVersion,
"Daft Punk - Discovery (2001) [FLAC]": VetoTitle,
"Random Access Memories (2013) [FLAC]": VetoArtist,
"Daft Punk - Discography 1997-2013 FLAC": VetoTitle,
"Daft Punk - Random Access Memories + Discovery + Homework Collection": VetoDiscography,
"Daft Punk - Random Access Memories (2013) 1080p BluRay": VetoVideo,
"Daft Punk - Random Access Memories (Single) 2013": VetoType,
"Daft Punk - Random Access Memories (Deluxe 10th Anniversary Edition) 2023": "",
} {
if got := judge(ram, name).Veto; got != veto {
t.Errorf("%q: veto %q, want %q", name, got, veto)
}
}
if !judge(ram, "Daft Punk - Random Access Memories (2013) [FLAC]").YearAgrees {
t.Error("the year did not agree")
}
}
func TestJudge_WordsTheWantedTitleHoldsDoNotVeto(t *testing.T) {
hole := Want{Artist: "Hole", Title: "Live Through This", PrimaryType: "Album"}
if m := judge(hole, "Hole - Live Through This (1994) [FLAC]"); m.Veto != "" {
t.Errorf("Live Through This: %s", m.Reason)
}
alive := Want{Artist: "Daft Punk", Title: "Alive 2007", PrimaryType: "Album", SecondaryTypes: []string{"Live"}}
if m := judge(alive, "Daft Punk - Alive 2007 (Live) [FLAC]"); m.Veto != "" {
t.Errorf("a wanted live album: %s", m.Reason)
}
alive.SecondaryTypes = nil
if m := judge(alive, "Daft Punk - Alive 2007 (Live) [FLAC]"); m.Veto != VetoVersion {
t.Errorf("live without the Live type: veto %q", m.Veto)
}
}
func TestJudge_SpellingsOfNames(t *testing.T) {
for _, c := range []struct {
w Want
name string
}{
{Want{Artist: "AC/DC", Title: "Back in Black"}, "ACDC - Back In Black (1980) FLAC"},
{Want{Artist: "AC/DC", Title: "Back in Black"}, "AC-DC - Back in Black [MP3 320]"},
{Want{Artist: "Sigur Rós", Title: "Ágætis byrjun"}, "Sigur Ros - Agaetis Byrjun (1999) [FLAC]"},
{Want{Artist: "Simon & Garfunkel", Title: "Bookends"}, "Simon and Garfunkel - Bookends"},
{Want{Artist: "The Beatles", Title: "Abbey Road"}, "Beatles - Abbey Road (Remastered 2009)"},
{Want{Artist: "Florence + the Machine", Title: "Lungs"}, "Florence and The Machine - Lungs (2009)"},
{Want{Artist: "Guns N' Roses", Title: "Appetite for Destruction"}, "Guns N Roses - Appetite For Destruction"},
} {
if m := judge(c.w, c.name); m.Veto != "" {
t.Errorf("%q for %s / %s: %s", c.name, c.w.Artist, c.w.Title, m.Reason)
}
}
// One word is never matched run together: Muse is not inside Museum.
if m := judge(Want{Artist: "Muse", Title: "Absolution"}, "Museum - Absolution"); m.Veto != VetoArtist {
t.Errorf("Museum read as Muse: %q", m.Veto)
}
}
func TestJudge_ASelfTitledAlbumNeedsMoreThanTheName(t *testing.T) {
weezer := Want{Artist: "Weezer", Title: "Weezer", Year: 1994, PrimaryType: "Album"}
if m := judge(weezer, "Weezer - Weezer (Blue Album) [FLAC]"); m.Doubt != "" || m.Veto != "" {
t.Errorf("named twice: %+v", m)
}
if m := judge(weezer, "Weezer (1994) [FLAC]"); m.Doubt != "" {
t.Errorf("the year agrees: %+v", m)
}
if m := judge(weezer, "Weezer (Green Album) FLAC"); m.Doubt == "" {
t.Error("a self-titled name with nothing else was taken as sure")
}
}
func TestTierOf_TheLadder(t *testing.T) {
for _, c := range []struct {
r Release
cat int
want Tier
}{
{Release{Format: FormatMP3, Bitrate: 256}, 0, TierLossyStandard},
{Release{Format: FormatMP3, Bitrate: 128}, 0, TierLossyPoor},
{Release{Format: FormatMP3, Bitrate: 96}, 0, TierLossyTrash},
{Release{Format: FormatMP3, VBR: "V2"}, 0, TierLossyStandard},
{Release{Format: FormatMP3}, 0, TierLossyStandard},
{Release{Format: FormatAAC, Bitrate: 192}, 0, TierLossyStandard},
{Release{Format: FormatALAC, SampleRate: 96}, 0, TierLosslessHiRes},
{Release{Format: FormatDSD}, 0, TierLosslessHiRes},
{Release{}, 3040, TierLossless},
{Release{}, 3010, TierUnknown},
} {
if got := TierOf(c.r, c.cat); got != c.want {
t.Errorf("TierOf(%+v, %d) = %s, want %s", c.r, c.cat, got, c.want)
}
}
if Rank(TierLossless) <= Rank(TierLossyHigh) || Rank(TierUnknown) >= Rank(TierLossyTrash) {
t.Error("the ladder is out of order")
}
if !NewProfile([]string{"lossless"}).Allows(TierLossless) || NewProfile([]string{"lossless"}).Allows(TierUnknown) {
t.Error("profile")
}
}
func files(paths ...string) []File {
out := make([]File, len(paths))
for i, p := range paths {
out[i] = File{Index: i, Path: p, Size: 1 << 20}
}
return out
}
func numbered(n int, ext string) []string {
var out []string
for i := 1; i <= n; i++ {
out = append(out, fmt.Sprintf("Album/%02d - Track.%s", i, ext))
}
return out
}
func TestJudgeFiles_WhatTheTorrentHolds(t *testing.T) {
w := Want{MinTracks: 10, MaxTracks: 13}
if veto, _, _ := JudgeFiles(w, files(numbered(13, "flac")...)); veto != "" {
t.Errorf("a whole album: %s", veto)
}
if veto, reason, _ := JudgeFiles(w, files(numbered(3, "flac")...)); veto != VetoIncomplete || !strings.Contains(reason, "3 tracks") {
t.Errorf("three tracks: %s %s", veto, reason)
}
if veto, _, _ := JudgeFiles(w, files("Concert/Concert.mkv", "Concert/cover.jpg")); veto != VetoVideo {
t.Errorf("a video: %s", veto)
}
if veto, _, _ := JudgeFiles(w, files("Album.rar", "Album.r00")); veto != VetoNoAudio {
t.Errorf("an archive: %s", veto)
}
if _, _, doubt := JudgeFiles(w, files(numbered(60, "mp3")...)); doubt == "" {
t.Error("sixty tracks for a thirteen-track album raised no doubt")
}
if c := Inspect(files(append(numbered(12, "flac"), "Album/cover.jpg", "Album/album.cue")...)); c.Format != FormatFLAC || c.Audio != 12 {
t.Errorf("inspect = %+v", c)
}
}
// bencode writes the few shapes a test torrent needs.
func bencode(v any) string {
switch x := v.(type) {
case string:
return fmt.Sprintf("%d:%s", len(x), x)
case int:
return fmt.Sprintf("i%de", x)
case []any:
var b strings.Builder
b.WriteString("l")
for _, e := range x {
b.WriteString(bencode(e))
}
return b.String() + "e"
case [][2]any: // an ordered dictionary
var b strings.Builder
b.WriteString("d")
for _, kv := range x {
b.WriteString(bencode(kv[0]) + bencode(kv[1]))
}
return b.String() + "e"
}
panic(fmt.Sprintf("bencode %T", v))
}
func TestParseTorrent_FilesAndInfohash(t *testing.T) {
info := bencode([][2]any{
{"files", []any{
[][2]any{{"length", 300}, {"path", []any{"01 - One.flac"}}},
[][2]any{{"attr", "p"}, {"length", 12}, {"path", []any{".pad", "12"}}},
[][2]any{{"length", 200}, {"path", []any{"Scans", "front.jpg"}}},
}},
{"name", "Artist - Album"},
{"piece length", 16384},
{"pieces", strings.Repeat("x", 20)},
})
body := "d" + bencode("announce") + bencode("http://tracker/") + bencode("info") + info + "e"
meta, err := ParseTorrent([]byte(body))
if err != nil {
t.Fatal(err)
}
sum := sha1.Sum([]byte(info))
if meta.InfoHash != hex.EncodeToString(sum[:]) {
t.Errorf("infohash = %s", meta.InfoHash)
}
if len(meta.Files) != 2 || meta.Files[0].Path != "Artist - Album/01 - One.flac" || meta.Files[1].Path != "Artist - Album/Scans/front.jpg" {
t.Errorf("files = %+v", meta.Files)
}
if _, err := ParseTorrent([]byte("<html>not a torrent</html>")); err == nil {
t.Error("HTML parsed as a torrent")
}
}
+178
View File
@@ -0,0 +1,178 @@
package release
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
)
// Reading a .torrent file, ported from Fabled Librarian (#4463). A .torrent
// is the metadata: its info dictionary names every file, and its SHA-1 is the
// infohash. So a release offered as a .torrent link can be judged on what it
// holds before any of it is downloaded. Bencode is four shapes; walking them
// here costs less than a dependency.
// TorrentMeta is what a .torrent says about itself.
type TorrentMeta struct {
// InfoHash is the v1 infohash: the SHA-1 of the info dictionary's own
// bytes, lowercase hex.
InfoHash string
Name string
Files []File
}
// ErrNotTorrent is bytes that are not a .torrent.
var ErrNotTorrent = errors.New("not a .torrent file")
// maxTorrentBytes bounds what is read, as Exchange bounds what it accepts.
const maxTorrentBytes = 10 << 20
// ParseTorrent reads a .torrent's info dictionary.
func ParseTorrent(b []byte) (TorrentMeta, error) {
if len(b) == 0 || len(b) > maxTorrentBytes || b[0] != 'd' {
return TorrentMeta{}, ErrNotTorrent
}
d := &bdecoder{b: b}
root, err := d.value(0)
if err != nil {
return TorrentMeta{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
}
top, ok := root.(map[string]any)
if !ok || d.infoEnd == 0 {
return TorrentMeta{}, fmt.Errorf("%w: no info dictionary", ErrNotTorrent)
}
info, ok := top["info"].(map[string]any)
if !ok {
return TorrentMeta{}, fmt.Errorf("%w: no info dictionary", ErrNotTorrent)
}
sum := sha1.Sum(b[d.infoStart:d.infoEnd])
out := TorrentMeta{InfoHash: hex.EncodeToString(sum[:])}
out.Name, _ = info["name"].(string)
files, multi := info["files"].([]any)
if !multi {
// A single-file torrent: the name IS the file.
length, _ := info["length"].(int64)
out.Files = []File{{Path: out.Name, Size: length}}
return out, nil
}
for index, f := range files {
entry, ok := f.(map[string]any)
if !ok {
continue
}
parts, _ := entry["path"].([]any)
segs := []string{out.Name}
for _, p := range parts {
if s, ok := p.(string); ok {
segs = append(segs, s)
}
}
length, _ := entry["length"].(int64)
// BEP 47 padding files carry no content.
if attr, _ := entry["attr"].(string); strings.Contains(attr, "p") {
continue
}
out.Files = append(out.Files, File{Index: index, Path: strings.Join(segs, "/"), Size: length})
}
return out, nil
}
// bdecoder walks bencode, and remembers where the top-level info dictionary
// sits so its raw bytes can be hashed exactly as published.
type bdecoder struct {
b []byte
i int
infoStart, infoEnd int
}
// maxDepth: a real torrent nests four deep. Anything past this is hostile.
const maxDepth = 32
func (d *bdecoder) value(depth int) (any, error) {
if depth > maxDepth {
return nil, errors.New("nested too deep")
}
if d.i >= len(d.b) {
return nil, errors.New("truncated")
}
switch c := d.b[d.i]; {
case c == 'i':
end := bytes.IndexByte(d.b[d.i:], 'e')
if end < 0 {
return nil, errors.New("unterminated integer")
}
n, err := strconv.ParseInt(string(d.b[d.i+1:d.i+end]), 10, 64)
if err != nil {
return nil, err
}
d.i += end + 1
return n, nil
case c == 'l':
d.i++
var out []any
for d.i < len(d.b) && d.b[d.i] != 'e' {
v, err := d.value(depth + 1)
if err != nil {
return nil, err
}
out = append(out, v)
}
if d.i >= len(d.b) {
return nil, errors.New("unterminated list")
}
d.i++
return out, nil
case c == 'd':
d.i++
out := map[string]any{}
for d.i < len(d.b) && d.b[d.i] != 'e' {
k, err := d.str()
if err != nil {
return nil, err
}
start := d.i
v, err := d.value(depth + 1)
if err != nil {
return nil, err
}
if depth == 0 && k == "info" {
d.infoStart, d.infoEnd = start, d.i
}
// Pieces are 20 bytes a piece and never read: not kept.
if k != "pieces" {
out[k] = v
}
}
if d.i >= len(d.b) {
return nil, errors.New("unterminated dictionary")
}
d.i++
return out, nil
case c >= '0' && c <= '9':
return d.str()
default:
return nil, fmt.Errorf("unexpected %q", c)
}
}
func (d *bdecoder) str() (string, error) {
colon := bytes.IndexByte(d.b[d.i:], ':')
if colon < 0 {
return "", errors.New("unterminated string length")
}
n, err := strconv.Atoi(string(d.b[d.i : d.i+colon]))
if err != nil || n < 0 {
return "", errors.New("bad string length")
}
start := d.i + colon + 1
if start+n > len(d.b) {
return "", errors.New("string runs past the end")
}
d.i = start + n
return string(d.b[start : start+n]), nil
}