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
+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
}