352 lines
8.4 KiB
Go
352 lines
8.4 KiB
Go
package tracker
|
|
|
|
import (
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/anacrolix/torrent/metainfo"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
|
"homelab.lan/music-agregator/internal/release"
|
|
)
|
|
|
|
var (
|
|
bitratePattern = regexp.MustCompile(`(?i)(\d{2,3})\s*kbps`)
|
|
hiResPatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`(?i)(\d{1,2})\s*[Bb]it\s*[-/]?\s*(\d{2,3}(?:\.\d)?)\s*[kK][Hh][Zz]`),
|
|
regexp.MustCompile(`(?i)\[?\s*(?:FLAC|Flac)\s+(\d{1,2})\s*[-/]\s*(\d{2,3}(?:\.\d)?)\s*\]?`),
|
|
regexp.MustCompile(`(?i)\[?\s*(\d{1,2})\s*[Bb]it\s*\]?`),
|
|
}
|
|
sourcePattern = regexp.MustCompile(`(?i)\[(CD|WEB|Vinyl|LP|Cassette|MC|DVD|Blu-?Ray|SACD|DAT)\]`)
|
|
ripTypePattern = regexp.MustCompile(`(?i)(vinyl\s*rip|SACD[- ]?R|HDCD|DSD\d*|tape\s*rip)`)
|
|
)
|
|
|
|
var audioExtensions = map[string]release.AudioFormat{
|
|
".flac": release.FormatFLAC,
|
|
".mp3": release.FormatMP3,
|
|
".aac": release.FormatAAC,
|
|
".m4a": release.FormatAAC,
|
|
".ape": release.FormatAPE,
|
|
".wv": release.FormatWavPack,
|
|
".alac": release.FormatALAC,
|
|
".ogg": release.FormatOGG,
|
|
".wav": release.FormatWAV,
|
|
}
|
|
|
|
type GenericParser struct{}
|
|
|
|
func NewGenericParser() *GenericParser {
|
|
return &GenericParser{}
|
|
}
|
|
|
|
func (p *GenericParser) ParseTorrent(torrentData []byte, album *metadataPb.Album) *release.Release {
|
|
r := &release.Release{}
|
|
|
|
p.fillFromMetadata(r, album)
|
|
p.fillFromTorrent(r, torrentData)
|
|
|
|
r.ParsedSuccessfully = r.Artist != "" && r.Album != ""
|
|
if !r.ParsedSuccessfully {
|
|
r.ParseErrors = append(r.ParseErrors, "missing artist or album")
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func (p *GenericParser) Parse(title string) *release.Release {
|
|
r := &release.Release{RawTitle: title}
|
|
|
|
p.fillFromTitle(r, title)
|
|
|
|
r.ParsedSuccessfully = r.Artist != "" && r.Album != ""
|
|
if !r.ParsedSuccessfully {
|
|
r.ParseErrors = append(r.ParseErrors, "missing artist or album")
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func (p *GenericParser) fillFromMetadata(r *release.Release, album *metadataPb.Album) {
|
|
if album == nil {
|
|
return
|
|
}
|
|
|
|
r.Album = album.GetTitle()
|
|
|
|
if len(album.GetArtists()) > 0 {
|
|
r.Artist = album.GetArtists()[0].GetArtist().GetName()
|
|
}
|
|
|
|
if album.GetReleaseDate() != "" {
|
|
if year, err := strconv.Atoi(album.GetReleaseDate()[:4]); err == nil {
|
|
r.Year = year
|
|
}
|
|
}
|
|
|
|
switch strings.ToLower(album.GetAlbumType()) {
|
|
case "album":
|
|
r.Type = release.TypeAlbum
|
|
case "ep":
|
|
r.Type = release.TypeEP
|
|
case "single":
|
|
r.Type = release.TypeSingle
|
|
case "compilation":
|
|
r.Type = release.TypeCompilation
|
|
case "soundtrack":
|
|
r.Type = release.TypeSoundtrack
|
|
case "live":
|
|
r.Type = release.TypeLive
|
|
}
|
|
|
|
for _, g := range album.GetGenres() {
|
|
r.Genres = append(r.Genres, g.GetName())
|
|
}
|
|
|
|
if album.GetLabel() != nil {
|
|
r.Label = album.GetLabel().GetName()
|
|
}
|
|
|
|
r.TrackCount = int(album.GetTotalTracks())
|
|
r.ReleaseCount = int(album.GetTotalDiscs())
|
|
|
|
log.Trace().
|
|
Str("artist", r.Artist).
|
|
Str("album", r.Album).
|
|
Int("year", r.Year).
|
|
Str("type", r.Type.String()).
|
|
Msg("filled from metadata")
|
|
}
|
|
|
|
func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte) {
|
|
if len(torrentData) == 0 {
|
|
return
|
|
}
|
|
|
|
mi, err := metainfo.Load(strings.NewReader(string(torrentData)))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to parse torrent data")
|
|
r.ParseErrors = append(r.ParseErrors, "failed to parse torrent: "+err.Error())
|
|
return
|
|
}
|
|
|
|
info, err := mi.UnmarshalInfo()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to unmarshal torrent info")
|
|
r.ParseErrors = append(r.ParseErrors, "failed to unmarshal torrent info: "+err.Error())
|
|
return
|
|
}
|
|
|
|
r.RawTitle = info.Name
|
|
r.InfoHash = mi.HashInfoBytes().HexString()
|
|
|
|
formatCounts := make(map[release.AudioFormat]int)
|
|
formatSizes := make(map[release.AudioFormat]int64)
|
|
|
|
if len(info.Files) == 0 {
|
|
ext := strings.ToLower(filepath.Ext(info.Name))
|
|
if fmt, ok := audioExtensions[ext]; ok {
|
|
r.Format = fmt
|
|
r.AudioFileCount = 1
|
|
r.TotalAudioSize = info.Length
|
|
}
|
|
} else {
|
|
for _, f := range info.Files {
|
|
path := filepath.Join(f.Path...)
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
name := strings.TrimSuffix(filepath.Base(path), ext)
|
|
|
|
if fmt, ok := audioExtensions[ext]; ok {
|
|
formatCounts[fmt]++
|
|
formatSizes[fmt] += f.Length
|
|
r.TrackNames = append(r.TrackNames, cleanTrackName(name))
|
|
}
|
|
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png":
|
|
r.HasCoverArt = true
|
|
case ".cue":
|
|
r.HasCueSheet = true
|
|
case ".log":
|
|
r.HasRipLog = true
|
|
}
|
|
}
|
|
|
|
var dominantFormat release.AudioFormat
|
|
var maxCount int
|
|
for fmt, count := range formatCounts {
|
|
if count > maxCount {
|
|
maxCount = count
|
|
dominantFormat = fmt
|
|
}
|
|
}
|
|
r.Format = dominantFormat
|
|
r.AudioFileCount = maxCount
|
|
r.TotalAudioSize = formatSizes[dominantFormat]
|
|
}
|
|
|
|
if r.HasRipLog {
|
|
r.Source = release.SourceCD
|
|
}
|
|
|
|
if r.TrackCount == 0 {
|
|
r.TrackCount = r.AudioFileCount
|
|
}
|
|
|
|
p.fillFromTitle(r, info.Name)
|
|
p.deduceFromFileSize(r)
|
|
|
|
log.Trace().
|
|
Str("hash", r.InfoHash).
|
|
Str("format", r.Format.String()).
|
|
Int("audio_files", r.AudioFileCount).
|
|
Int64("audio_size", r.TotalAudioSize).
|
|
Bool("cover", r.HasCoverArt).
|
|
Bool("cue", r.HasCueSheet).
|
|
Bool("log", r.HasRipLog).
|
|
Int("bit_depth", r.BitDepth).
|
|
Int("sample_rate", r.SampleRate).
|
|
Str("bitrate", r.Bitrate).
|
|
Msg("filled from torrent")
|
|
}
|
|
|
|
func (p *GenericParser) fillFromTitle(r *release.Release, title string) {
|
|
if title == "" {
|
|
return
|
|
}
|
|
|
|
if m := bitratePattern.FindStringSubmatch(title); len(m) > 1 {
|
|
r.Bitrate = m[1] + " kbps"
|
|
}
|
|
|
|
for _, pattern := range hiResPatterns {
|
|
m := pattern.FindStringSubmatch(title)
|
|
if len(m) < 2 {
|
|
continue
|
|
}
|
|
if r.BitDepth == 0 {
|
|
if bd, err := strconv.Atoi(m[1]); err == nil {
|
|
r.BitDepth = bd
|
|
}
|
|
}
|
|
if len(m) > 2 && r.SampleRate == 0 {
|
|
if sr, err := strconv.ParseFloat(m[2], 64); err == nil {
|
|
r.SampleRate = int(sr * 1000)
|
|
}
|
|
}
|
|
if r.BitDepth > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if m := sourcePattern.FindStringSubmatch(title); len(m) > 1 && r.Source == release.SourceUnknown {
|
|
switch strings.ToUpper(m[1]) {
|
|
case "CD":
|
|
r.Source = release.SourceCD
|
|
case "WEB":
|
|
r.Source = release.SourceWEB
|
|
case "VINYL", "LP":
|
|
r.Source = release.SourceVinyl
|
|
case "CASSETTE", "MC":
|
|
r.Source = release.SourceCassette
|
|
case "DVD":
|
|
r.Source = release.SourceDVD
|
|
case "BLU-RAY", "BLURAY":
|
|
r.Source = release.SourceBluRay
|
|
}
|
|
}
|
|
|
|
if m := ripTypePattern.FindStringSubmatch(title); len(m) > 1 {
|
|
r.RipType = m[1]
|
|
}
|
|
|
|
log.Trace().
|
|
Str("bitrate", r.Bitrate).
|
|
Int("bit_depth", r.BitDepth).
|
|
Int("sample_rate", r.SampleRate).
|
|
Str("source", r.Source.String()).
|
|
Str("rip_type", r.RipType).
|
|
Msg("filled from title")
|
|
}
|
|
|
|
func (p *GenericParser) deduceFromFileSize(r *release.Release) {
|
|
if r.AudioFileCount == 0 || r.TotalAudioSize == 0 {
|
|
return
|
|
}
|
|
|
|
avgFileSize := r.TotalAudioSize / int64(r.AudioFileCount)
|
|
avgFileSizeMB := float64(avgFileSize) / (1024 * 1024)
|
|
|
|
switch {
|
|
case r.Format.IsLossless():
|
|
if r.BitDepth > 0 && r.SampleRate > 0 {
|
|
return
|
|
}
|
|
|
|
// Average FLAC file size per ~4 min track:
|
|
// 16/44.1 ≈ 25-35 MB 24/48 ≈ 40-60 MB
|
|
// 24/96 ≈ 80-120 MB 24/192 ≈ 160-240 MB
|
|
switch {
|
|
case avgFileSizeMB >= 130:
|
|
p.setIfMissing(r, 24, 192000)
|
|
case avgFileSizeMB >= 65:
|
|
p.setIfMissing(r, 24, 96000)
|
|
case avgFileSizeMB >= 38:
|
|
p.setIfMissing(r, 24, 48000)
|
|
default:
|
|
p.setIfMissing(r, 16, 44100)
|
|
}
|
|
|
|
log.Trace().
|
|
Float64("avg_file_mb", avgFileSizeMB).
|
|
Int("deduced_bit_depth", r.BitDepth).
|
|
Int("deduced_sample_rate", r.SampleRate).
|
|
Msg("deduced lossless quality from file size")
|
|
|
|
case r.Format == release.FormatMP3:
|
|
if r.Bitrate != "" {
|
|
return
|
|
}
|
|
|
|
// Average MP3 file size per ~4 min track:
|
|
// 128 kbps ≈ 3.5-4 MB 192 kbps ≈ 5-6 MB
|
|
// 256 kbps ≈ 7-8 MB 320 kbps ≈ 9-10 MB
|
|
switch {
|
|
case avgFileSizeMB >= 8.5:
|
|
r.Bitrate = "320 kbps"
|
|
case avgFileSizeMB >= 6.5:
|
|
r.Bitrate = "256 kbps"
|
|
case avgFileSizeMB >= 4.5:
|
|
r.Bitrate = "192 kbps"
|
|
default:
|
|
r.Bitrate = "128 kbps"
|
|
}
|
|
|
|
log.Trace().
|
|
Float64("avg_file_mb", avgFileSizeMB).
|
|
Str("deduced_bitrate", r.Bitrate).
|
|
Msg("deduced mp3 bitrate from file size")
|
|
}
|
|
}
|
|
|
|
func (p *GenericParser) setIfMissing(r *release.Release, bitDepth int, sampleRate int) {
|
|
if r.BitDepth == 0 {
|
|
r.BitDepth = bitDepth
|
|
}
|
|
if r.SampleRate == 0 {
|
|
r.SampleRate = sampleRate
|
|
}
|
|
}
|
|
|
|
var trackNumberPrefix = regexp.MustCompile(`^\d{1,3}[\s.\-]+`)
|
|
|
|
func cleanTrackName(name string) string {
|
|
cleaned := trackNumberPrefix.ReplaceAllString(name, "")
|
|
if cleaned == "" {
|
|
return name
|
|
}
|
|
return strings.TrimSpace(cleaned)
|
|
}
|