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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user