Files
minstrel/internal/release/parse.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

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