Files
bvandeusenandClaude Opus 5.5 89b3b44779
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
feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
- 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>
2026-10-10 08:56:47 -04:00

290 lines
8.5 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 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
}