Implement Jackett search entpoint
This commit is contained in:
+1
-103
@@ -1,108 +1,6 @@
|
||||
package indexer
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||
)
|
||||
|
||||
type Indexer interface {
|
||||
Search()
|
||||
Search(query string, limit int32, indexer string) (SearchResult, error)
|
||||
Capabilities(indexerName string) (IndexerCapabilities, error)
|
||||
}
|
||||
|
||||
type IndexerCapabilities struct {
|
||||
XMLName xml.Name `xml:"caps"`
|
||||
Server Server `xml:"server"`
|
||||
Limits Limits `xml:"limits"`
|
||||
Searching Searching `xml:"searching"`
|
||||
Categories []Category `xml:"categories>category"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Title string `xml:"title,attr"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
Default int `xml:"default,attr"`
|
||||
Max int `xml:"max,attr"`
|
||||
}
|
||||
|
||||
type Searching struct {
|
||||
Search SearchCapability `xml:"search"`
|
||||
TvSearch SearchCapability `xml:"tv-search"`
|
||||
MovieSearch SearchCapability `xml:"movie-search"`
|
||||
MusicSearch SearchCapability `xml:"music-search"`
|
||||
AudioSearch SearchCapability `xml:"audio-search"`
|
||||
BookSearch SearchCapability `xml:"book-search"`
|
||||
}
|
||||
|
||||
type SearchCapability struct {
|
||||
Available string `xml:"available,attr"`
|
||||
SupportedParams string `xml:"supportedParams,attr"`
|
||||
SearchEngine string `xml:"searchEngine,attr"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Subcats []Subcat `xml:"subcat"`
|
||||
}
|
||||
|
||||
type Subcat struct {
|
||||
ID int `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
|
||||
func (c *IndexerCapabilities) ToProto() *pb.CapabilitiesResponse {
|
||||
return &pb.CapabilitiesResponse{
|
||||
Server: &pb.Server{
|
||||
Title: c.Server.Title,
|
||||
},
|
||||
Limits: &pb.Limits{
|
||||
Default: int32(c.Limits.Default),
|
||||
Max: int32(c.Limits.Max),
|
||||
},
|
||||
Searching: &pb.Searching{
|
||||
Search: c.Searching.Search.toProto(),
|
||||
TvSearch: c.Searching.TvSearch.toProto(),
|
||||
MovieSearch: c.Searching.MovieSearch.toProto(),
|
||||
MusicSearch: c.Searching.MusicSearch.toProto(),
|
||||
AudioSearch: c.Searching.AudioSearch.toProto(),
|
||||
BookSearch: c.Searching.BookSearch.toProto(),
|
||||
},
|
||||
Categories: c.categoriesToProto(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchCapability) toProto() *pb.SearchCapability {
|
||||
var params []string
|
||||
if s.SupportedParams != "" {
|
||||
params = strings.Split(s.SupportedParams, ",")
|
||||
}
|
||||
return &pb.SearchCapability{
|
||||
Available: s.Available == "yes",
|
||||
SupportedParams: params,
|
||||
SearchEngine: s.SearchEngine,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IndexerCapabilities) categoriesToProto() []*pb.Category {
|
||||
categories := make([]*pb.Category, len(c.Categories))
|
||||
for i, cat := range c.Categories {
|
||||
subcats := make([]*pb.Subcat, len(cat.Subcats))
|
||||
for j, sub := range cat.Subcats {
|
||||
subcats[j] = &pb.Subcat{
|
||||
Id: int32(sub.ID),
|
||||
Name: sub.Name,
|
||||
}
|
||||
}
|
||||
categories[i] = &pb.Category{
|
||||
Id: int32(cat.ID),
|
||||
Name: cat.Name,
|
||||
Subcats: subcats,
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package indexer
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||
)
|
||||
|
||||
type IndexerCapabilities struct {
|
||||
XMLName xml.Name `xml:"caps"`
|
||||
Server Server `xml:"server"`
|
||||
Limits Limits `xml:"limits"`
|
||||
Searching Searching `xml:"searching"`
|
||||
Categories []Category `xml:"categories>category"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Title string `xml:"title,attr"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
Default int `xml:"default,attr"`
|
||||
Max int `xml:"max,attr"`
|
||||
}
|
||||
|
||||
type Searching struct {
|
||||
Search SearchCapability `xml:"search"`
|
||||
TvSearch SearchCapability `xml:"tv-search"`
|
||||
MovieSearch SearchCapability `xml:"movie-search"`
|
||||
MusicSearch SearchCapability `xml:"music-search"`
|
||||
AudioSearch SearchCapability `xml:"audio-search"`
|
||||
BookSearch SearchCapability `xml:"book-search"`
|
||||
}
|
||||
|
||||
type SearchCapability struct {
|
||||
Available string `xml:"available,attr"`
|
||||
SupportedParams string `xml:"supportedParams,attr"`
|
||||
SearchEngine string `xml:"searchEngine,attr"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Subcats []Subcat `xml:"subcat"`
|
||||
}
|
||||
|
||||
type Subcat struct {
|
||||
ID int `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
|
||||
func (c *IndexerCapabilities) ToProto() *pb.CapabilitiesResponse {
|
||||
return &pb.CapabilitiesResponse{
|
||||
Server: &pb.Server{
|
||||
Title: c.Server.Title,
|
||||
},
|
||||
Limits: &pb.Limits{
|
||||
Default: int32(c.Limits.Default),
|
||||
Max: int32(c.Limits.Max),
|
||||
},
|
||||
Searching: &pb.Searching{
|
||||
Search: c.Searching.Search.toProto(),
|
||||
TvSearch: c.Searching.TvSearch.toProto(),
|
||||
MovieSearch: c.Searching.MovieSearch.toProto(),
|
||||
MusicSearch: c.Searching.MusicSearch.toProto(),
|
||||
AudioSearch: c.Searching.AudioSearch.toProto(),
|
||||
BookSearch: c.Searching.BookSearch.toProto(),
|
||||
},
|
||||
Categories: c.categoriesToProto(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchCapability) toProto() *pb.SearchCapability {
|
||||
var params []string
|
||||
if s.SupportedParams != "" {
|
||||
params = strings.Split(s.SupportedParams, ",")
|
||||
}
|
||||
return &pb.SearchCapability{
|
||||
Available: s.Available == "yes",
|
||||
SupportedParams: params,
|
||||
SearchEngine: s.SearchEngine,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IndexerCapabilities) categoriesToProto() []*pb.Category {
|
||||
categories := make([]*pb.Category, len(c.Categories))
|
||||
for i, cat := range c.Categories {
|
||||
subcats := make([]*pb.Subcat, len(cat.Subcats))
|
||||
for j, sub := range cat.Subcats {
|
||||
subcats[j] = &pb.Subcat{
|
||||
Id: int32(sub.ID),
|
||||
Name: sub.Name,
|
||||
}
|
||||
}
|
||||
categories[i] = &pb.Category{
|
||||
Id: int32(cat.ID),
|
||||
Name: cat.Name,
|
||||
Subcats: subcats,
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
@@ -25,8 +25,43 @@ func NewIndexer(cfg config.Config) Indexer {
|
||||
}
|
||||
}
|
||||
|
||||
func (indexer *JacketIndexer) Search() {
|
||||
log.Warn().Msg("Unimplemented method search on the Jacket Indexer")
|
||||
func (indexer *JacketIndexer) Search(query string, limit int32, tracker string) (SearchResult, error) {
|
||||
searchTracker := "all"
|
||||
if len(tracker) != 0 {
|
||||
searchTracker = tracker
|
||||
}
|
||||
|
||||
url := indexer.cfg.Indexer.Url
|
||||
uri := fmt.Sprintf("%v/api/v2.0/indexers/%v/results/torznab?apikey=%v&limit=%d&q=%v&t=search", url, searchTracker, indexer.cfg.Indexer.ApiKey, limit, query)
|
||||
|
||||
log.Debug().Str("uri", uri).Msg("Sending search request")
|
||||
|
||||
req, err := http.NewRequest("GET", uri, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error creating request")
|
||||
return SearchResult{}, err
|
||||
}
|
||||
|
||||
resp, err := indexer.client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error making search request")
|
||||
return SearchResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error reading search response body")
|
||||
return SearchResult{}, err
|
||||
}
|
||||
|
||||
var searchResult SearchResult
|
||||
if err := xml.Unmarshal(body, &searchResult); err != nil {
|
||||
log.Error().Err(err).Msg("Error parsing search XML")
|
||||
return SearchResult{}, err
|
||||
}
|
||||
|
||||
return searchResult, nil
|
||||
}
|
||||
|
||||
func (indexer *JacketIndexer) Capabilities(indexerName string) (IndexerCapabilities, error) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package indexer
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Items []Item `xml:"channel>item"` // Directly targets items inside channel
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Guid string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Size int64 `xml:"size"`
|
||||
Description string `xml:"description"`
|
||||
Categories []string `xml:"category"`
|
||||
Enclosure Enclosure `xml:"enclosure"`
|
||||
TorznabAttrs []TorznabAttr `xml:"attr"`
|
||||
}
|
||||
|
||||
type Enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type TorznabAttr struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"value,attr"`
|
||||
}
|
||||
|
||||
func (sr *SearchResult) ToProto() *pb.SearchResponse {
|
||||
pbItems := make([]*pb.SearchItem, len(sr.Items))
|
||||
|
||||
for i, item := range sr.Items {
|
||||
// Map Torznab Attributes
|
||||
pbAttrs := make([]*pb.TorznabAttr, len(item.TorznabAttrs))
|
||||
for j, attr := range item.TorznabAttrs {
|
||||
pbAttrs[j] = &pb.TorznabAttr{
|
||||
Name: attr.Name,
|
||||
Value: attr.Value,
|
||||
}
|
||||
}
|
||||
|
||||
// Map the Item
|
||||
pbItems[i] = &pb.SearchItem{
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
Guid: item.Guid,
|
||||
PubDate: item.PubDate,
|
||||
Size: item.Size,
|
||||
Description: item.Description,
|
||||
Categories: item.Categories,
|
||||
Enclosure: &pb.Enclosure{
|
||||
Url: item.Enclosure.URL,
|
||||
Length: item.Enclosure.Length,
|
||||
Type: item.Enclosure.Type,
|
||||
},
|
||||
TorznabAttrs: pbAttrs,
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.SearchResponse{
|
||||
Result: pbItems,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||
"homelab.lan/music-agregator/internal/config"
|
||||
)
|
||||
@@ -27,7 +28,14 @@ func NewIndexerServer(cfg config.Config) (*IndexerServer, error) {
|
||||
}
|
||||
|
||||
func (server *IndexerServer) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) {
|
||||
return &pb.SearchResponse{}, nil
|
||||
log.Debug().Str("query", req.GetQuery()).Int32("limit", req.GetLimit()).Str("indexer", req.GetTracker()).Msg("Running search with these prams")
|
||||
searchResult, err := server.indexer.Search(req.GetQuery(), req.GetLimit(), req.GetTracker())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to search in indexer")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return searchResult.ToProto(), nil
|
||||
}
|
||||
|
||||
func (server *IndexerServer) Capabilities(ctx context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package release
|
||||
|
||||
type Type int
|
||||
|
||||
const (
|
||||
TypeUnknown Type = iota
|
||||
TypeAlbum
|
||||
TypeEP
|
||||
TypeSingle
|
||||
TypeDiscography
|
||||
TypeCollection
|
||||
TypeCompilation
|
||||
TypeSoundtrack
|
||||
TypeLive
|
||||
TypeBootleg
|
||||
)
|
||||
|
||||
func (t Type) String() string {
|
||||
switch t {
|
||||
case TypeAlbum:
|
||||
return "album"
|
||||
case TypeEP:
|
||||
return "ep"
|
||||
case TypeSingle:
|
||||
return "single"
|
||||
case TypeDiscography:
|
||||
return "discography"
|
||||
case TypeCollection:
|
||||
return "collection"
|
||||
case TypeCompilation:
|
||||
return "compilation"
|
||||
case TypeSoundtrack:
|
||||
return "soundtrack"
|
||||
case TypeLive:
|
||||
return "live"
|
||||
case TypeBootleg:
|
||||
return "bootleg"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (t Type) Priority() int {
|
||||
switch t {
|
||||
case TypeAlbum, TypeEP, TypeSingle:
|
||||
return 1
|
||||
case TypeLive:
|
||||
return 2
|
||||
case TypeSoundtrack:
|
||||
return 3
|
||||
case TypeCollection:
|
||||
return 4
|
||||
case TypeDiscography:
|
||||
return 5
|
||||
case TypeCompilation:
|
||||
return 6
|
||||
case TypeBootleg:
|
||||
return 7
|
||||
default:
|
||||
return 99
|
||||
}
|
||||
}
|
||||
|
||||
type AudioFormat int
|
||||
|
||||
const (
|
||||
FormatUnknown AudioFormat = iota
|
||||
FormatFLAC
|
||||
FormatMP3
|
||||
FormatAAC
|
||||
FormatAPE
|
||||
FormatWavPack
|
||||
FormatALAC
|
||||
FormatOGG
|
||||
FormatWAV
|
||||
)
|
||||
|
||||
func (f AudioFormat) String() string {
|
||||
switch f {
|
||||
case FormatFLAC:
|
||||
return "FLAC"
|
||||
case FormatMP3:
|
||||
return "MP3"
|
||||
case FormatAAC:
|
||||
return "AAC"
|
||||
case FormatAPE:
|
||||
return "APE"
|
||||
case FormatWavPack:
|
||||
return "WavPack"
|
||||
case FormatALAC:
|
||||
return "ALAC"
|
||||
case FormatOGG:
|
||||
return "OGG"
|
||||
case FormatWAV:
|
||||
return "WAV"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (f AudioFormat) IsLossless() bool {
|
||||
switch f {
|
||||
case FormatFLAC, FormatAPE, FormatWavPack, FormatALAC, FormatWAV:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type Source int
|
||||
|
||||
const (
|
||||
SourceUnknown Source = iota
|
||||
SourceCD
|
||||
SourceWEB
|
||||
SourceVinyl
|
||||
SourceCassette
|
||||
SourceDVD
|
||||
SourceBluRay
|
||||
)
|
||||
|
||||
func (s Source) String() string {
|
||||
switch s {
|
||||
case SourceCD:
|
||||
return "CD"
|
||||
case SourceWEB:
|
||||
return "WEB"
|
||||
case SourceVinyl:
|
||||
return "Vinyl"
|
||||
case SourceCassette:
|
||||
return "Cassette"
|
||||
case SourceDVD:
|
||||
return "DVD"
|
||||
case SourceBluRay:
|
||||
return "BluRay"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
RawTitle string
|
||||
|
||||
Artist string
|
||||
Album string
|
||||
Year int
|
||||
YearEnd int
|
||||
|
||||
Type Type
|
||||
Genres []string
|
||||
|
||||
Format AudioFormat
|
||||
Source Source
|
||||
Bitrate string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
RipType string
|
||||
|
||||
ReleaseCount int
|
||||
Tags []string
|
||||
Label string
|
||||
CatalogNum string
|
||||
|
||||
ParsedSuccessfully bool
|
||||
ParseErrors []string
|
||||
}
|
||||
|
||||
func (r *Release) IsDiscography() bool {
|
||||
return r.Type == TypeDiscography || r.Type == TypeCollection
|
||||
}
|
||||
|
||||
func (r *Release) IsSingleRelease() bool {
|
||||
return r.Type == TypeAlbum || r.Type == TypeEP || r.Type == TypeSingle
|
||||
}
|
||||
|
||||
func (r *Release) HasYearRange() bool {
|
||||
return r.YearEnd > 0 && r.YearEnd != r.Year
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user