Implement Jackett search entpoint

This commit is contained in:
Alexander
2026-05-04 22:48:14 +02:00
parent 8ffa92276e
commit bfef1b6c79
43 changed files with 4437 additions and 114 deletions
+256
View File
@@ -0,0 +1,256 @@
package parser
import (
"strconv"
"strings"
"homelab.lan/music-agregator/internal/release"
)
type BaseParser struct{}
func (p *BaseParser) NewRelease(title string) *release.Release {
return &release.Release{
RawTitle: title,
ParsedSuccessfully: true,
}
}
func (p *BaseParser) ExtractGenres(title string) []string {
match := genrePattern.FindStringSubmatch(title)
if len(match) < 2 {
return nil
}
raw := match[1]
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '/' || r == ';'
})
var genres []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
genres = append(genres, trimmed)
}
}
return genres
}
func (p *BaseParser) StripGenrePrefix(title string) string {
return genrePattern.ReplaceAllString(title, "")
}
func (p *BaseParser) StripLeadingTags(title string) string {
return leadingTagsPattern.ReplaceAllString(title, "")
}
func (p *BaseParser) ExtractYear(title string) int {
match := yearPattern.FindStringSubmatch(title)
if len(match) < 2 {
return 0
}
year, _ := strconv.Atoi(match[1])
return year
}
func (p *BaseParser) ExtractYearRange(title string) (int, int) {
match := yearRangePattern.FindStringSubmatch(title)
if len(match) < 3 {
year := p.ExtractYear(title)
return year, 0
}
start, _ := strconv.Atoi(match[1])
end, _ := strconv.Atoi(match[2])
return start, end
}
func (p *BaseParser) ExtractFormat(title string) release.AudioFormat {
match := formatPattern.FindStringSubmatch(title)
if len(match) < 2 {
return release.FormatUnknown
}
format := strings.ToUpper(match[1])
switch {
case format == "FLAC":
return release.FormatFLAC
case format == "MP3":
return release.FormatMP3
case format == "AAC":
return release.FormatAAC
case format == "APE":
return release.FormatAPE
case format == "WV" || format == "WAVPACK":
return release.FormatWavPack
case format == "ALAC":
return release.FormatALAC
case format == "OGG":
return release.FormatOGG
case format == "WAV":
return release.FormatWAV
default:
return release.FormatUnknown
}
}
func (p *BaseParser) ExtractBitrate(title string) string {
if strings.Contains(strings.ToLower(title), "lossless") {
return "lossless"
}
match := bitratePattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
if match[1] != "" {
return match[1] + " kbps"
}
if match[2] != "" {
return "V" + match[2]
}
if match[3] != "" {
return "VBR ~" + match[3] + " kbps"
}
if match[4] != "" && match[5] != "" {
return "VBR " + match[4] + "-" + match[5] + " kbps"
}
return ""
}
func (p *BaseParser) ExtractRipType(title string) string {
match := ripTypePattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
return strings.ToLower(match[1])
}
func (p *BaseParser) ExtractSource(title string) release.Source {
match := sourceTagPattern.FindStringSubmatch(title)
if len(match) < 2 {
if strings.Contains(strings.ToLower(title), "web") {
return release.SourceWEB
}
return release.SourceUnknown
}
tag := strings.ToUpper(match[1])
switch tag {
case "CD":
return release.SourceCD
case "WEB":
return release.SourceWEB
case "LP", "VINYL", "MINI-LP", "EP", "12\"", "10\"", "7\"":
return release.SourceVinyl
case "SACD", "DVDA", "HDAD":
return release.SourceDVD
default:
return release.SourceUnknown
}
}
func (p *BaseParser) ExtractHiRes(title string) (bitDepth int, sampleRate int) {
match := hiResPattern.FindStringSubmatch(title)
if len(match) < 3 {
return 0, 0
}
bitDepth, _ = strconv.Atoi(match[1])
sr := match[2]
if strings.Contains(sr, ".") {
f, _ := strconv.ParseFloat(sr, 64)
sampleRate = int(f * 1000)
} else {
sampleRate, _ = strconv.Atoi(sr)
sampleRate *= 1000
}
return bitDepth, sampleRate
}
func (p *BaseParser) ExtractSpecialTags(title string) []string {
matches := specialTagPattern.FindAllStringSubmatch(title, -1)
var tags []string
for _, match := range matches {
if len(match) >= 2 {
tags = append(tags, match[1])
}
}
return tags
}
func (p *BaseParser) ExtractReleaseCount(title string) int {
match := releaseCountPattern.FindStringSubmatch(title)
if len(match) < 2 {
return 0
}
count, _ := strconv.Atoi(match[1])
return count
}
func (p *BaseParser) ExtractLabel(title string) string {
match := labelPattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
return strings.TrimSpace(match[1])
}
func (p *BaseParser) ExtractCatalogNum(title string) string {
match := catalogNumPattern.FindStringSubmatch(title)
if len(match) < 2 {
return ""
}
return match[1]
}
func (p *BaseParser) DetectType(title string) release.Type {
switch {
case discographyPattern.MatchString(title):
return release.TypeDiscography
case collectionPattern.MatchString(title):
return release.TypeCollection
case compilationPattern.MatchString(title):
return release.TypeCompilation
case anthologyPattern.MatchString(title):
return release.TypeCollection
case soundtrackPattern.MatchString(title):
return release.TypeSoundtrack
case bootlegPattern.MatchString(title):
return release.TypeBootleg
case livePattern.MatchString(title):
return release.TypeLive
case epPattern.MatchString(title):
return release.TypeEP
case singlePattern.MatchString(title):
return release.TypeSingle
case bestOfPattern.MatchString(title):
return release.TypeCompilation
default:
return release.TypeAlbum
}
}
func (p *BaseParser) ExtractArtistAlbum(title string) (artist string, album string) {
cleaned := p.StripGenrePrefix(title)
cleaned = p.StripLeadingTags(cleaned)
cleaned = trailingTechPattern.ReplaceAllString(cleaned, "")
if match := standardTitlePattern.FindStringSubmatch(title); len(match) >= 3 {
return strings.TrimSpace(match[1]), strings.TrimSpace(match[2])
}
if match := altTitlePattern.FindStringSubmatch(title); len(match) >= 3 {
return strings.TrimSpace(match[1]), strings.TrimSpace(match[2])
}
parts := strings.SplitN(cleaned, " - ", 3)
if len(parts) >= 2 {
artist = strings.TrimSpace(parts[0])
albumPart := strings.TrimSpace(parts[1])
albumPart = yearPattern.ReplaceAllString(albumPart, "")
albumPart = strings.Trim(albumPart, " -,")
album = albumPart
}
return artist, album
}
func (p *BaseParser) AddError(r *release.Release, err string) {
r.ParseErrors = append(r.ParseErrors, err)
r.ParsedSuccessfully = false
}
@@ -0,0 +1,40 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type ClassicalParser struct {
BaseParser
}
func NewClassicalParser() *ClassicalParser {
return &ClassicalParser{}
}
func (p *ClassicalParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Classical"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*ClassicalParser)(nil)
@@ -0,0 +1,56 @@
package parser
import (
"strings"
"homelab.lan/music-agregator/internal/release"
)
type DiscographyParser struct {
BaseParser
}
func NewDiscographyParser() *DiscographyParser {
return &DiscographyParser{}
}
func (p *DiscographyParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if collectionPattern.MatchString(title) {
r.Type = release.TypeCollection
} else {
r.Type = release.TypeDiscography
}
r.Artist = p.extractDiscographyArtist(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
func (p *DiscographyParser) extractDiscographyArtist(title string) string {
if match := discographyTitlePattern.FindStringSubmatch(title); len(match) >= 2 {
return strings.TrimSpace(match[1])
}
if match := collectionTitlePattern.FindStringSubmatch(title); len(match) >= 2 {
return strings.TrimSpace(match[1])
}
artist, _ := p.ExtractArtistAlbum(title)
return artist
}
var _ Parser = (*DiscographyParser)(nil)
@@ -0,0 +1,152 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestDiscographyParser(t *testing.T) {
p := NewDiscographyParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantYearEnd int
wantReleaseCount int
wantType release.Type
wantFormat release.AudioFormat
wantParseOK bool
}{
{
name: "Russian discography with ALAC",
title: "(Metalcore, progressive metalcore, alternative metal, mathcore) [CD`12] Architects - Дискография / Discography - 2006-2025, ALAC (tracks+.cue), lossless",
wantArtist: "Architects",
wantYear: 2006,
wantYearEnd: 2025,
wantType: release.TypeDiscography,
wantFormat: release.FormatALAC,
wantParseOK: true,
},
{
name: "discography with CD count",
title: "(Rock / Hard Rock / Power-Pop) [CD] Cheap Trick - Дискография - 1977-2021 (78 CD), FLAC (image+.cue), lossless",
wantArtist: "Cheap Trick",
wantYear: 1977,
wantYearEnd: 2021,
wantReleaseCount: 78,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "mixed CD and WEB",
title: "Pompeya - Дискография | Discography (3 CD, 6 WEB) - 2011-2015, FLAC (tracks+.cue, tracks/web), lossless",
wantArtist: "Pompeya",
wantYear: 2011,
wantYearEnd: 2015,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "large discography with releases count",
title: "(Rock) Александр Башлачёв ● Дискография (1994~2025) (35 выпусков, 47 CD / 2 Digital Release), FLAC (image+.cue), lossless",
wantArtist: "Александр Башлачёв",
wantYear: 1994,
wantYearEnd: 2025,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "very large discography",
title: "(Rock) Аквариум и Борис Гребенщиков (БГ) - Дискография - 1973–2023 (222 издания, 245 CD), FLAC (image+.cue), lossless",
wantArtist: "Аквариум и Борис Гребенщиков (БГ)",
wantYear: 1973,
wantYearEnd: 2023,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "metal discography",
title: "(Heavy Metal) [CD] Saxon - Дискография (58 CD) - 1979-2024, FLAC (image+.cue), lossless",
wantArtist: "Saxon",
wantYear: 1979,
wantYearEnd: 2024,
wantReleaseCount: 58,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "detailed Queen discography",
title: "(Progressive Hard Rock Fusion) [CD] Queen The Discography / Дискография (15 Studio, 11 Live, 13 Compilation, 63 Singles, 2 Collaboration, 7 Box Set, 243 issues, 336 CD) - 1973-2015, FLAC (image+.cue), lossless",
wantArtist: "Queen",
wantYear: 1973,
wantYearEnd: 2015,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "English discography",
title: "(Rock, Pop) [CD] U2 - Discography (1980-2017), FLAC (tracks+.cue), lossless",
wantArtist: "U2",
wantYear: 1980,
wantYearEnd: 2017,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "death metal discography",
title: "(Technical Brutal Death Metal) [CD] Nile - Discography (1994 - 2024) 13 CD, FLAC (image+.cue), lossless",
wantArtist: "Nile",
wantYear: 1994,
wantYearEnd: 2024,
wantReleaseCount: 13,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "collection keyword",
title: "(Pop) Madonna - Коллекция / Collection - 65 релизов (2 Albums, 22 Singles, 13 Megamixes, 8 Live, 17 Collections, 3 Bonus) (1982-2012), MP3, 128-320, VBR kbps",
wantArtist: "Madonna",
wantYear: 1982,
wantYearEnd: 2012,
wantType: release.TypeCollection,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantYearEnd != 0 && r.YearEnd != tt.wantYearEnd {
t.Errorf("YearEnd = %d, want %d", r.YearEnd, tt.wantYearEnd)
}
if tt.wantReleaseCount != 0 && r.ReleaseCount != tt.wantReleaseCount {
t.Errorf("ReleaseCount = %d, want %d", r.ReleaseCount, tt.wantReleaseCount)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
})
}
}
@@ -0,0 +1,37 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type GeneralParser struct {
BaseParser
}
func NewGeneralParser() *GeneralParser {
return &GeneralParser{}
}
func (p *GeneralParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*GeneralParser)(nil)
@@ -0,0 +1,166 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestGeneralParser(t *testing.T) {
p := NewGeneralParser()
tests := []struct {
name string
title string
wantArtist string
wantAlbum string
wantYear int
wantFormat release.AudioFormat
wantType release.Type
wantGenres []string
wantSource release.Source
wantRipType string
wantBitrate string
wantParseOK bool
}{
{
name: "standard CD rip with genre",
title: "(Rock) [CD] Thin Lizzy - Acoustic Sessions - 2024 (Decca Records EU 2025), FLAC (image+.cue), lossless",
wantArtist: "Thin Lizzy",
wantAlbum: "Acoustic Sessions",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantType: release.TypeAlbum,
wantGenres: []string{"Rock"},
wantSource: release.SourceCD,
wantRipType: "image+.cue",
wantBitrate: "lossless",
wantParseOK: true,
},
{
name: "multi-genre CD rip",
title: "(Hard Rock, Glam Rock, Progressive Rock, Art Rock, Heavy Metal) [CD] Queen Queen I (2 CD) 2024 , FLAC (image+.cue), lossless",
wantArtist: "Queen",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantType: release.TypeAlbum,
wantGenres: []string{"Hard Rock", "Glam Rock", "Progressive Rock", "Art Rock", "Heavy Metal"},
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "WEB release with tracks",
title: "(Progressive Rock) [WEB] Opeth - In Cauda Venenum (Extended Edition) - 2019/2022, FLAC (tracks), lossless",
wantArtist: "Opeth",
wantYear: 2019,
wantFormat: release.FormatFLAC,
wantSource: release.SourceWEB,
wantRipType: "tracks",
wantParseOK: true,
},
{
name: "Japan release",
title: "(Pop-Rock Soft-Rock) [CD] Sting - The Soul Cages (Expanded Edition) - 2025 [Japan], FLAC (image+.cue), lossless",
wantArtist: "Sting",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "live album",
title: "(Rock) [CD] Bryan Adams - Live at the Royal Albert Hall - 2024, FLAC (image+.cue), lossless",
wantArtist: "Bryan Adams",
wantType: release.TypeLive,
wantYear: 2024,
wantParseOK: true,
},
{
name: "soundtrack",
title: "(Pop) [CD] Celine Dion - I AM - Celine Dion (Original Motion Picture Soundtrack) - 2024 [Japan], FLAC (image+.cue), lossless",
wantArtist: "Celine Dion",
wantType: release.TypeSoundtrack,
wantYear: 2024,
wantParseOK: true,
},
{
name: "deluxe box set",
title: "(Rock) [CD] Bryan Adams - Roll With The Punches (Deluxe Box Set) - 2025, FLAC (image+.cue), lossless",
wantArtist: "Bryan Adams",
wantYear: 2025,
wantParseOK: true,
},
{
name: "CDS single",
title: "(Heavy Metal) [CDS] Bruce Dickinson - Resurrection Men - 2024, FLAC (image+.cue), lossless",
wantArtist: "Bruce Dickinson",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "tracks+cue format",
title: "(Classic Rock) [CD] The Who - Who Are You (Super Deluxe Edition) - 2025, FLAC (tracks+cue), lossless",
wantArtist: "The Who",
wantYear: 2025,
wantRipType: "tracks+cue",
wantParseOK: true,
},
{
name: "WEB with special artist name",
title: "(Chamber Pop) [WEB] Florence + the Machine - Ceremonials (Digital Deluxe Edition) - 2011, FLAC (tracks), lossless",
wantArtist: "Florence + the Machine",
wantYear: 2011,
wantSource: release.SourceWEB,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantAlbum != "" && r.Album != tt.wantAlbum {
t.Errorf("Album = %q, want %q", r.Album, tt.wantAlbum)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
if len(tt.wantGenres) > 0 {
if len(r.Genres) != len(tt.wantGenres) {
t.Errorf("Genres count = %d, want %d", len(r.Genres), len(tt.wantGenres))
}
}
})
}
}
@@ -0,0 +1,47 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type HiResParser struct {
BaseParser
}
func NewHiResParser() *HiResParser {
return &HiResParser{}
}
func (p *HiResParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatFLAC
}
r.Bitrate = "lossless"
if r.BitDepth == 0 {
if dsdMatch := dsdPattern.FindStringSubmatch(title); len(dsdMatch) >= 3 {
r.BitDepth = 1
r.Tags = append(r.Tags, dsdMatch[1]+dsdMatch[2])
}
}
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*HiResParser)(nil)
@@ -0,0 +1,133 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestHiResParser(t *testing.T) {
p := NewHiResParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantBitDepth int
wantSampleRate int
wantSource release.Source
wantParseOK bool
}{
{
name: "TR24 OF official release",
title: "[TR24][OF] Matteo Mancuso - Route 96 - 2026 (Progressive Rock, Jazz Fusion, Instrumental)",
wantArtist: "Matteo Mancuso",
wantYear: 2026,
wantParseOK: true,
},
{
name: "TR24 OF LDR tag",
title: "[TR24][OF][LDR] Sepultura - The Cloud Of Unknowing - 2026 (Groove Thrash Metal)",
wantArtist: "Sepultura",
wantYear: 2026,
wantParseOK: true,
},
{
name: "24bit 48kHz in title",
title: "[TR24][OF] U2 - Days Of Ash [EP] [24bit-48kHz] - 2026 (Pop Rock, Soft Rock)",
wantArtist: "U2",
wantYear: 2026,
wantBitDepth: 24,
wantSampleRate: 48000,
wantParseOK: true,
},
{
name: "LP 24/192",
title: "(Blues, R&B) [LP] [24/192] Etta James - At Last! - 1960/2026, FLAC (tracks)",
wantArtist: "Etta James",
wantYear: 1960,
wantBitDepth: 24,
wantSampleRate: 192000,
wantSource: release.SourceVinyl,
wantParseOK: true,
},
{
name: "DSD128",
title: "(Progressive rock) [LP] [1/5,64 MHz] The Neal Morse Band L. I. F. T. - 2026, DSD 128 (tracks)",
wantArtist: "The Neal Morse Band",
wantYear: 2026,
wantSource: release.SourceVinyl,
wantParseOK: true,
},
{
name: "DSD256 with label",
title: "(Jazz, Bop) [LP] [DSD256] Oscar Peterson Trio & Clark Terry 'Oscar Peterson Trio + One' [Acoustic Sounds Series] - 1964, 2026, dsf (tracks)",
wantArtist: "Oscar Peterson Trio & Clark Terry",
wantYear: 1964,
wantParseOK: true,
},
{
name: "24/96 modal jazz",
title: "(Modal, Jazz) [LP] [24/96] John Coltrane - The Tiberi Tapes: A Preview Of The Mythic Recordings (2026 Record Store Day) - 2026, FLAC (tracks)",
wantArtist: "John Coltrane",
wantYear: 2026,
wantBitDepth: 24,
wantSampleRate: 96000,
wantParseOK: true,
},
{
name: "2xLP compilation",
title: "(Electronic, Funk / Soul, Disco, House) [2xLP] [24/192] Various - The Many Faces Of Daft Punk - 2020( Compilation), FLAC (tracks)",
wantArtist: "Various",
wantYear: 2020,
wantBitDepth: 24,
wantSampleRate: 192000,
wantParseOK: true,
},
{
name: "SACD-R",
title: "[SACD-R][OF] Wynton Marsalis - The London Concert - 2000 (Classical)",
wantArtist: "Wynton Marsalis",
wantYear: 2000,
wantParseOK: true,
},
{
name: "SACD-R DSD",
title: "[SACD-R][DSD][OF]Scott Hamilton, Paolo Birro - Pure Imagination - 2019 (Jazz)",
wantArtist: "Scott Hamilton, Paolo Birro",
wantYear: 2019,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth {
t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth)
}
if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate {
t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
})
}
}
+40
View File
@@ -0,0 +1,40 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type JazzParser struct {
BaseParser
}
func NewJazzParser() *JazzParser {
return &JazzParser{}
}
func (p *JazzParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Jazz"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*JazzParser)(nil)
@@ -0,0 +1,35 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type LabelPacksParser struct {
BaseParser
}
func NewLabelPacksParser() *LabelPacksParser {
return &LabelPacksParser{}
}
func (p *LabelPacksParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = release.TypeCollection
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if r.Label == "" {
p.AddError(r, "failed to extract label name")
}
return r
}
var _ Parser = (*LabelPacksParser)(nil)
@@ -0,0 +1,143 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestLabelPacksParser(t *testing.T) {
p := NewLabelPacksParser()
tests := []struct {
name string
title string
wantLabel string
wantYear int
wantYearEnd int
wantReleaseCount int
wantFormat release.AudioFormat
wantParseOK bool
}{
{
name: "standard label pack",
title: "(Drum & Bass) [WEB] Label: Metalheadz (370 релизов), 1994-2025, FLAC (tracks), lossless",
wantLabel: "Metalheadz",
wantYear: 1994,
wantYearEnd: 2025,
wantReleaseCount: 370,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "label with part number",
title: "(Trance, House) [WEB] Label - Black Hole Recordings (Part 3) (401 Releases) - 2009-2023, FLAC (tracks / images), lossless",
wantLabel: "Black Hole Recordings (Part 3)",
wantYear: 2009,
wantYearEnd: 2023,
wantReleaseCount: 401,
wantParseOK: true,
},
{
name: "small label",
title: "(Trance) [WEB, CD] Label: Solaris Recordings (7 Releases) - 2005-2014, FLAC (tracks, tracks+.cue), lossless",
wantLabel: "Solaris Recordings",
wantYear: 2005,
wantYearEnd: 2014,
wantParseOK: true,
},
{
name: "techno label with brackets",
title: "(Techno, IDM, Experimental) [WEB,CD] Label - Stroboscopic Artefacts (2009-2022) [96 Releases], FLAC (tracks) (tracks+.cue), lossless",
wantLabel: "Stroboscopic Artefacts",
wantYear: 2009,
wantYearEnd: 2022,
wantParseOK: true,
},
{
name: "multi-genre label",
title: "(Techno, Ambient, IDM, Experimental, Drum n Bass) [WEB,CD] Label - Auxiliary [2010 - 2021] [65xReleases], FLAC (tracks) (tracks+.cue), lossless",
wantLabel: "Auxiliary",
wantYear: 2010,
wantYearEnd: 2021,
wantParseOK: true,
},
{
name: "Russian release count",
title: "(Techno, Minimal, Deep Tech, Melodic House & Techno) [WEB] Label: FCKNG SERIOUS (121 релиз), 2015-2025, FLAC (tracks, image), lossless",
wantLabel: "FCKNG SERIOUS",
wantYear: 2015,
wantYearEnd: 2025,
wantReleaseCount: 121,
wantParseOK: true,
},
{
name: "progressive house label",
title: "(Progressive House, Trance, Techno) [WEB] Label: Bedrock Records (519 релизов), 1999-2025, (FLAC) lossless (tracks, image)",
wantLabel: "Bedrock Records",
wantYear: 1999,
wantYearEnd: 2025,
wantReleaseCount: 519,
wantParseOK: true,
},
{
name: "large techno label",
title: "(Techno) [WEB,CD] Label - Planet Rhythm Records [1994 - 2021] [443xReleases], FLAC (tracks) (tracks+.cue, image+.cue), lossless",
wantLabel: "Planet Rhythm Records",
wantYear: 1994,
wantYearEnd: 2021,
wantParseOK: true,
},
{
name: "label with featured artists",
title: "(Trance, Breaks, House) [WEB] Label: Digital Emotions (47 Releases) (Incl. Fonarev pres. F13, Poshout, Second Sine & etc.) - 2010-2025, FLAC (tracks), lossless",
wantLabel: "Digital Emotions",
wantYear: 2010,
wantYearEnd: 2025,
wantParseOK: true,
},
{
name: "bondage music",
title: "(Deep House, Minimal) [WEB] Label: Bondage Music (173 релиза), 2006-2025, (FLAC) lossless (tracks, image)",
wantLabel: "Bondage Music",
wantYear: 2006,
wantYearEnd: 2025,
wantReleaseCount: 173,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantLabel != "" && r.Label != tt.wantLabel {
t.Errorf("Label = %q, want %q", r.Label, tt.wantLabel)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantYearEnd != 0 && r.YearEnd != tt.wantYearEnd {
t.Errorf("YearEnd = %d, want %d", r.YearEnd, tt.wantYearEnd)
}
if tt.wantReleaseCount != 0 && r.ReleaseCount != tt.wantReleaseCount {
t.Errorf("ReleaseCount = %d, want %d", r.ReleaseCount, tt.wantReleaseCount)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if r.Type != release.TypeCollection {
t.Errorf("Type = %v, want Collection", r.Type)
}
})
}
}
@@ -0,0 +1,39 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type LosslessParser struct {
BaseParser
}
func NewLosslessParser() *LosslessParser {
return &LosslessParser{}
}
func (p *LosslessParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatFLAC
}
r.Bitrate = "lossless"
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*LosslessParser)(nil)
@@ -0,0 +1,143 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestLosslessParser(t *testing.T) {
p := NewLosslessParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantSource release.Source
wantRipType string
wantParseOK bool
}{
{
name: "standard CD FLAC image",
title: "(Rock) [CD] Thin Lizzy - Acoustic Sessions - 2024 (Decca Records EU 2025), FLAC (image+.cue), lossless",
wantArtist: "Thin Lizzy",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantSource: release.SourceCD,
wantRipType: "image+.cue",
wantParseOK: true,
},
{
name: "WEB release tracks",
title: "(Progressive Rock) [WEB] Opeth - In Cauda Venenum (Extended Edition) - 2019/2022, FLAC (tracks), lossless",
wantArtist: "Opeth",
wantYear: 2019,
wantFormat: release.FormatFLAC,
wantSource: release.SourceWEB,
wantRipType: "tracks",
wantParseOK: true,
},
{
name: "APE format",
title: "(Jazz) [CD] Miles Davis - Kind of Blue - 1959, APE (image+.cue), lossless",
wantArtist: "Miles Davis",
wantYear: 1959,
wantFormat: release.FormatAPE,
wantSource: release.SourceCD,
wantParseOK: true,
},
{
name: "tracks+cue format",
title: "(Classic Rock) [CD] The Who - Who Are You (Super Deluxe Edition) - 2025, FLAC (tracks+cue), lossless",
wantArtist: "The Who",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantRipType: "tracks+cue",
wantParseOK: true,
},
{
name: "multi-disc set",
title: "(Hard Rock, Glam Rock, Progressive Rock, Art Rock, Heavy Metal) [CD] Queen Queen I (2 CD) 2024 , FLAC (image+.cue), lossless",
wantArtist: "Queen",
wantYear: 2024,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "Japan release",
title: "(Pop-Rock Soft-Rock) [CD] Sting - The Soul Cages (Expanded Edition) - 2025 [Japan], FLAC (image+.cue), lossless",
wantArtist: "Sting",
wantYear: 2025,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "WavPack format",
title: "(Progressive Rock) [CD] Yes - Close to the Edge - 1972, WV (image+.cue), lossless",
wantArtist: "Yes",
wantYear: 1972,
wantFormat: release.FormatWavPack,
wantParseOK: true,
},
{
name: "default to FLAC when format not specified",
title: "(Rock) [CD] Pink Floyd - The Wall - 1979 (image+.cue), lossless",
wantArtist: "Pink Floyd",
wantYear: 1979,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "heavy metal WEB",
title: "(Heavy Metal) [WEB] Heaven & Hell - Breaking Out Of Heaven: 2007-2009 - 2026, FLAC (tracks), lossless",
wantArtist: "Heaven & Hell",
wantYear: 2026,
wantSource: release.SourceWEB,
wantParseOK: true,
},
{
name: "melodic rock WEB",
title: "(Melodic Rock, Progressive Rock) [WEB] James LaBrie - Beautiful Shade Of Grey - 2022, FLAC (tracks), lossless",
wantArtist: "James LaBrie",
wantYear: 2022,
wantSource: release.SourceWEB,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
}
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
}
if r.Bitrate != "lossless" {
t.Errorf("Bitrate = %q, want lossless", r.Bitrate)
}
})
}
}
@@ -0,0 +1,38 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type LossyParser struct {
BaseParser
}
func NewLossyParser() *LossyParser {
return &LossyParser{}
}
func (p *LossyParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatMP3
}
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*LossyParser)(nil)
@@ -0,0 +1,134 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestLossyParser(t *testing.T) {
p := NewLossyParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantFormat release.AudioFormat
wantBitrate string
wantType release.Type
wantParseOK bool
}{
{
name: "VBR V0",
title: "(Pop) VA - Pop Classics Top 100 - 2012, MP3, VBR V0",
wantArtist: "VA",
wantYear: 2012,
wantFormat: release.FormatMP3,
wantBitrate: "V0",
wantType: release.TypeCompilation,
wantParseOK: true,
},
{
name: "VBR V0 kbps suffix",
title: "(Pop/Rock) VA - 101 Ultimate 80's (5 CD) - 2011, MP3 (tracks), VBR V0 kbps",
wantArtist: "VA",
wantYear: 2011,
wantFormat: release.FormatMP3,
wantBitrate: "V0",
wantParseOK: true,
},
{
name: "VBR V1",
title: "(Rock) VA - Greatest Ever! Rock The Definitive Collection (3 CD) - 2006, MP3 (tracks), VBR V1 kbps",
wantArtist: "VA",
wantYear: 2006,
wantBitrate: "V1",
wantParseOK: true,
},
{
name: "VBR V2",
title: "(Classic Rock) VA - Twist & Shout - 2005, MP3, VBR V2",
wantArtist: "VA",
wantYear: 2005,
wantBitrate: "V2",
wantParseOK: true,
},
{
name: "VBR range",
title: "(Pop, Rock) VA - The Essential 1980s - 2010, MP3 (tracks), VBR 192-320 kbps",
wantArtist: "VA",
wantYear: 2010,
wantBitrate: "VBR 192-320 kbps",
wantParseOK: true,
},
{
name: "CBR 320",
title: "(Pop) VA - Bravo Hits, Vol. 128 [2 CD] - 2025, MP3, 320 kbps",
wantArtist: "VA",
wantYear: 2025,
wantBitrate: "320 kbps",
wantParseOK: true,
},
{
name: "CBR 256",
title: "(rock'n'roll) Rock-n-roll. The best hits, MP3 (tracks), 256 kbps",
wantFormat: release.FormatMP3,
wantBitrate: "256 kbps",
wantParseOK: true,
},
{
name: "year range in title",
title: "(Pop) VA - Bravo Hits vol. 31-59 - 2000-2007, MP3, VBR 192-320 kbps",
wantArtist: "VA",
wantYear: 2000,
wantParseOK: true,
},
{
name: "discography in lossy",
title: "(Alternative Metal / Post-Grunge) Breaking Benjamin - Discography: 23 Releases, 2001-2024, MP3, VBR V0/320 kbps",
wantArtist: "Breaking Benjamin",
wantYear: 2001,
wantType: release.TypeDiscography,
wantParseOK: true,
},
{
name: "bootleg release",
title: "(Eurodance) VA - Beat Mix Eurodance Vol 1-3 (Bootlegs) - 2009-2011, MP3 (image), VBR V2 / V0",
wantArtist: "VA",
wantYear: 2009,
wantType: release.TypeBootleg,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
if tt.wantType != release.TypeUnknown && r.Type != tt.wantType {
t.Errorf("Type = %v, want %v", r.Type, tt.wantType)
}
})
}
}
@@ -0,0 +1,40 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type MetalParser struct {
BaseParser
}
func NewMetalParser() *MetalParser {
return &MetalParser{}
}
func (p *MetalParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
if len(r.Genres) == 0 {
r.Genres = []string{"Metal"}
}
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.ReleaseCount = p.ExtractReleaseCount(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*MetalParser)(nil)
@@ -0,0 +1,7 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type Parser interface {
Parse(title string) *release.Release
}
@@ -0,0 +1,100 @@
package parser
import "regexp"
var (
// Genre at start: (Rock), (Electronic, Ambient), (Jazz / Blues)
genrePattern = regexp.MustCompile(`^\s*\(([^)]+)\)\s*`)
// Label pack: Label: Name or Label - Name
labelPattern = regexp.MustCompile(`(?i)Label[:\-]\s*([^-(\[]+)`)
// Year: single or range
yearPattern = regexp.MustCompile(`\b((?:19|20)\d{2})\b`)
yearRangePattern = regexp.MustCompile(`\b((?:19|20)\d{2})\s*[-]\s*((?:19|20)\d{2})\b`)
// Release count: (15 CD), (30 albums), 10 releases, (50 релизов), 13 CD
releaseCountPattern = regexp.MustCompile(`(?:\()?(\d+)\s*(?:CD|albums?|releases?|релиз(?:а|ов)?|альбом(?:а|ов)?)(?:\))?`)
// Audio formats
formatPattern = regexp.MustCompile(`(?i)\b(FLAC|APE|MP3|AAC|OGG|WV|WavPack|ALAC|WAV|DSD\d*|DST\d*)\b`)
// Bitrate: 320 kbps, V0, VBR 192-320 kbps, lossless
bitratePattern = regexp.MustCompile(`(?i)(?:(\d{2,3})\s*kbps|V([012])|VBR\s*(?:~?(\d+)|(\d+)-(\d+))\s*kbps|lossless)`)
// Rip type: image+.cue, tracks+.cue, tracks
ripTypePattern = regexp.MustCompile(`(?i)(image\+\.?cue|tracks?\+\.?cue|tracks?)`)
// Hi-Res bit depth / sample rate: [24/96], [24/192], [24bit-48kHz]
hiResPattern = regexp.MustCompile(`\[(\d+)(?:/|bit[/-])(\d+(?:\.\d+)?)\s*(?:kHz)?\]`)
// DSD formats: DSD64, DSD128, DST64
dsdPattern = regexp.MustCompile(`(?i)\b(DSD|DST)(64|128|256|512)\b`)
// Source tags: [CD], [WEB], [LP], [Vinyl], [SACD], [DVDA]
sourceTagPattern = regexp.MustCompile(`(?i)\[(CD|WEB|LP|Vinyl|SACD|DVDA|HDAD|MINI-LP|EP|12"|10"|7")\]`)
// Vinyl condition: [NM], [EX], [VG+], [VG], [G], [Mint], [SS]
vinylConditionPattern = regexp.MustCompile(`\[(Mint|SS|NM|EX|VG\+?|G|F/?P)\]`)
// Special tags: [AI], [WEB], [TR24], [OF], [RM], [restored], [declipped]
specialTagPattern = regexp.MustCompile(`\[(AI|WEB|TR24|OF|RM|restored|declipped)\]`)
// Discography keywords (Russian + English)
discographyPattern = regexp.MustCompile(`(?i)\b([Дд]искографи[яи]|[Dd]iscograph(?:y|ies))\b`)
// Collection keywords
collectionPattern = regexp.MustCompile(`(?i)\b([Кк]оллекци[яи]|[Cc]ollection)\b`)
// Compilation keywords
compilationPattern = regexp.MustCompile(`(?i)\b([Сс]борник|[Cc]ompilation|[Vv]arious\s*[Aa]rtists?|VA)\b`)
// Anthology keywords
anthologyPattern = regexp.MustCompile(`(?i)\b([Аа]нтологи[яи]|[Aa]nthology)\b`)
// Best of / Greatest hits keywords
bestOfPattern = regexp.MustCompile(`(?i)\b([Ии]збранное|[Лл]учшее|[Bb]est\s*[Oo]f|[Gg]reatest\s*[Hh]its)\b`)
// Live / Concert keywords
livePattern = regexp.MustCompile(`(?i)\b([Жж]ивой|[Кк]онцерт|[Ll]ive|[Cc]oncert|[Ll]ive\s*[Aa]t)\b`)
// Bootleg keywords
bootlegPattern = regexp.MustCompile(`(?i)\b([Бб]утлеги?|[Bb]ootlegs?|[Uu]nofficial)\b`)
// Soundtrack keywords
soundtrackPattern = regexp.MustCompile(`(?i)\b(OST|[Ss]oundtrack|[Сс]аундтрек|[Ss]core|[Мм]узыка\s*(?:к|из)\s*фильм[ау])\b`)
// Remaster keywords
remasterPattern = regexp.MustCompile(`(?i)\b([Рр]емастер|[Rr]emaster(?:ed)?|[Пп]ереиздани[ея]|[Rr]e-?issue)\b`)
// EP keywords
epPattern = regexp.MustCompile(`(?i)\b(EP|[Мм]ини[-\s]?[Аа]льбом|[Ee]xtended\s*[Pp]lay)\b`)
// Single keywords
singlePattern = regexp.MustCompile(`(?i)\b([Сс]ингл|[Ss]ingle)\b`)
// Standard title format: Artist - Album - Year or (Genre) Artist - Album - Year
// Captures: artist, album, year
standardTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-]+?)\s*[-]\s*(.+?)\s*[-]\s*((?:19|20)\d{2})`)
// Alternative: Artist - Album (Year)
altTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-]+?)\s*[-]\s*(.+?)\s*\(((?:19|20)\d{2})\)`)
// Discography title: Artist - Дискография (15 CD) [1990-2020, ...]
discographyTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-]+?)\s*[-]\s*(?:[Дд]искографи[яи]|[Dd]iscograph(?:y|ies))`)
// Collection title: Artist - Коллекция (50 CD) [1980-2019, ...]
collectionTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?:\[[^\]]+\]\s*)*([^-]+?)\s*[-]\s*(?:[Кк]оллекци[яи]|[Cc]ollection)`)
// Label pack title: (Genre) Label: Label Name (releases)
labelPackTitlePattern = regexp.MustCompile(`^(?:\([^)]+\)\s*)?(?i)Label:\s*([^(]+)`)
// Catalog number in brackets: [CAT001], [LABEL-001]
catalogNumPattern = regexp.MustCompile(`\[([A-Z]{2,}[-\s]?\d+[A-Z]*)\]`)
// Tags in brackets at start to strip: [RM], [restored], etc.
leadingTagsPattern = regexp.MustCompile(`^(\s*\[[^\]]+\]\s*)+`)
// Clean trailing technical info: , FLAC (image+.cue)
trailingTechPattern = regexp.MustCompile(`,?\s*(?:FLAC|APE|MP3|AAC|OGG|WV|WavPack|ALAC|WAV).*$`)
)
@@ -0,0 +1,36 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type SoundtracksParser struct {
BaseParser
}
func NewSoundtracksParser() *SoundtracksParser {
return &SoundtracksParser{}
}
func (p *SoundtracksParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = release.TypeSoundtrack
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Bitrate = p.ExtractBitrate(title)
r.Source = p.ExtractSource(title)
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*SoundtracksParser)(nil)
@@ -0,0 +1,44 @@
package parser
import "homelab.lan/music-agregator/internal/release"
type VinylDigitizationParser struct {
BaseParser
}
func NewVinylDigitizationParser() *VinylDigitizationParser {
return &VinylDigitizationParser{}
}
func (p *VinylDigitizationParser) Parse(title string) *release.Release {
r := p.NewRelease(title)
r.Genres = p.ExtractGenres(title)
r.Type = p.DetectType(title)
r.Year, r.YearEnd = p.ExtractYearRange(title)
r.Format = p.ExtractFormat(title)
r.Source = release.SourceVinyl
r.RipType = p.ExtractRipType(title)
r.Tags = p.ExtractSpecialTags(title)
r.Label = p.ExtractLabel(title)
r.CatalogNum = p.ExtractCatalogNum(title)
r.Artist, r.Album = p.ExtractArtistAlbum(title)
r.BitDepth, r.SampleRate = p.ExtractHiRes(title)
if r.Format == release.FormatUnknown {
r.Format = release.FormatFLAC
}
r.Bitrate = "lossless"
if condMatch := vinylConditionPattern.FindStringSubmatch(title); len(condMatch) >= 2 {
r.Tags = append(r.Tags, "Vinyl:"+condMatch[1])
}
if r.Artist == "" {
p.AddError(r, "failed to extract artist")
}
return r
}
var _ Parser = (*VinylDigitizationParser)(nil)
@@ -0,0 +1,147 @@
package parser
import (
"testing"
"homelab.lan/music-agregator/internal/release"
)
func TestVinylDigitizationParser(t *testing.T) {
p := NewVinylDigitizationParser()
tests := []struct {
name string
title string
wantArtist string
wantYear int
wantBitDepth int
wantSampleRate int
wantFormat release.AudioFormat
wantRipType string
wantParseOK bool
}{
{
name: "standard LP 24/192",
title: "(Pop-Rock/Punk) [LP] [24/192] Сектор Газа (Юрий Хой) - Ядрена вошь [Coloured, Remastered '2025] - 2026 (1990), WavPack (image+.cue)",
wantArtist: "Сектор Газа (Юрий Хой)",
wantYear: 2026,
wantBitDepth: 24,
wantSampleRate: 192000,
wantFormat: release.FormatWavPack,
wantRipType: "image+.cue",
wantParseOK: true,
},
{
name: "massive vinyl collection",
title: "(Synth-Pop) [LP/12''/10''/7''] [24/96] Depeche Mode - The Vinyl Collection (17 Albums, 66 Singles, 6 Compilations, 51 Bootlegs) (429 Releases) - 1981-2024, FLAC (tracks) lossless",
wantArtist: "Depeche Mode",
wantYear: 1981,
wantParseOK: true,
},
{
name: "2xLP 32bit",
title: "(Soft Rock, Pop Rock) [2xLP] [32/176.4] Genesis - Turn It On Again - The Hits - 1999(2024,Reissue, 25th anniversary.), WavPack (tracks)",
wantArtist: "Genesis",
wantYear: 1999,
wantBitDepth: 32,
wantSampleRate: 176400,
wantFormat: release.FormatWavPack,
wantParseOK: true,
},
{
name: "32/384 ultra high res",
title: "(Prog Rock) [LP] [32/384] Emerson, Lake & Palmer-Emerson, Lake & Palmer - 2025 (1970), WavPack (tracks)",
wantYear: 2025,
wantBitDepth: 32,
wantSampleRate: 384000,
wantFormat: release.FormatWavPack,
wantParseOK: true,
},
{
name: "soul LP",
title: "(Soul, Funk) [LP] [24/192] Curtis Mayfield - Curtis - 1970/2025, FLAC (tracks)",
wantArtist: "Curtis Mayfield",
wantYear: 1970,
wantBitDepth: 24,
wantSampleRate: 192000,
wantFormat: release.FormatFLAC,
wantParseOK: true,
},
{
name: "16/44 standard",
title: "(Rock) [LP] [16/44] Tony Sheridan - Collection 4LP - 1976-1987, FLAC (image+.cue)",
wantArtist: "Tony Sheridan",
wantYear: 1976,
wantParseOK: true,
},
{
name: "MFSL pressing",
title: "(Rock, Pop Rock) [LP] [24/96] Fleetwood Mac Mirage - 1982 (1984 MFSL 1-119), FLAC (tracks)",
wantArtist: "Fleetwood Mac",
wantYear: 1982,
wantParseOK: true,
},
{
name: "multiple LPs in one",
title: "(Rock) [LP] [24/96] 10cc - 2LP's - 1976, 1977, FLAC (tracks+.cue)",
wantArtist: "10cc",
wantYear: 1976,
wantRipType: "tracks+.cue",
wantParseOK: true,
},
{
name: "collection from vinyl",
title: "(Progressive Rock) [LP] [24/192] Marillion, Fish - Vinyl Collection - 1982-1994 (6 releases), FLAC (image+.cue)",
wantArtist: "Marillion, Fish",
wantYear: 1982,
wantParseOK: true,
},
{
name: "Japan vinyl",
title: "(Pop) [LP][24/96] Abba \"The Album\" Original Japan vinyl - 1977, FLAC (tracks)",
wantArtist: "Abba",
wantYear: 1977,
wantBitDepth: 24,
wantSampleRate: 96000,
wantParseOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if r.ParsedSuccessfully != tt.wantParseOK {
t.Errorf("ParsedSuccessfully = %v, want %v, errors: %v", r.ParsedSuccessfully, tt.wantParseOK, r.ParseErrors)
}
if tt.wantArtist != "" && r.Artist != tt.wantArtist {
t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist)
}
if tt.wantYear != 0 && r.Year != tt.wantYear {
t.Errorf("Year = %d, want %d", r.Year, tt.wantYear)
}
if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth {
t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth)
}
if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate {
t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate)
}
if tt.wantFormat != release.FormatUnknown && r.Format != tt.wantFormat {
t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat)
}
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
}
if r.Source != release.SourceVinyl {
t.Errorf("Source = %v, want Vinyl", r.Source)
}
})
}
}