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