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