Implement MonitorAlbum: search, parse, filter by quality, add best to qbittorrent

This commit is contained in:
Alexander
2026-05-07 23:21:21 +02:00
parent 79f3f145de
commit 8ad2734964
16 changed files with 1479 additions and 59 deletions
+278
View File
@@ -0,0 +1,278 @@
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)
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).
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")
}
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)
}
+171
View File
@@ -0,0 +1,171 @@
package tracker
import (
"os"
"testing"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/release"
)
func TestGenericParser_Parse(t *testing.T) {
p := NewGenericParser()
tests := []struct {
name string
title string
wantBitrate string
wantBitDepth int
wantSampleRate int
wantSource release.Source
wantRipType string
}{
{
name: "discography no hires",
title: "System Of A Down - Discography [FLAC Songs] [PMEDIA]",
},
{
name: "hiphop hires 24-44",
title: "Snoop Dogg - 10 Til' Midnight (2026 Hip Hop Rap) [Flac 24-44]",
wantBitDepth: 24,
wantSampleRate: 44000,
},
{
name: "pop hires 24bit",
title: "Sabrina Carpenter - Short n' Sweet [Deluxe] [2025] [Hi-Res FLAC 24bit]-Sc4r3cr0w",
wantBitDepth: 24,
},
{
name: "rock hires 24bit",
title: "Linkin Park - From Zero [Deluxe Edition] [2025] [Hi-Res] [FLAC-24bit]-Sc4r3cr0w",
},
{
name: "rock hires 24-48",
title: "Linkin Park - From Zero (2024) [24Bit-48kHz] FLAC [PMEDIA]",
wantBitDepth: 24,
wantSampleRate: 48000,
},
{
name: "hiphop hires 24-96",
title: "J. Cole - The Fall-Off (2026 Hip Hop Rap) [Flac 24-96]",
wantBitDepth: 24,
wantSampleRate: 96000,
},
{
name: "minimal format",
title: "Bjork-Bastards.2012.FLAC-NewAlbumReleases",
},
{
name: "vinyl hires",
title: "Gorillaz - Demon Days [Live From The Apollo Theater] [2025] [Vinyl Hi-Res] [FLAC-24bit]-Sc4r3cr0w",
},
{
name: "cd with log",
title: "Linkin Park - Meteora (Tracks, Log, Cue, Scans) (2003) [FLAC] 88",
},
{
name: "rock 16-44",
title: "Heart - Jupiters Darling (2004 Rock) [Flac 16-44]",
wantBitDepth: 16,
wantSampleRate: 44000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := p.Parse(tt.title)
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
}
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)
}
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
}
})
}
}
func TestGenericParser_ParseTorrent(t *testing.T) {
torrentData, err := os.ReadFile("/tmp/metallica.torrent")
if err != nil {
t.Skip("metallica.torrent not available")
}
album := &metadataPb.Album{
Title: "72 Seasons",
AlbumType: "Album",
ReleaseDate: "2023-04-14",
TotalTracks: 12,
TotalDiscs: 1,
Artists: []*metadataPb.ArtistCredit{
{Artist: &metadataPb.Artist{Name: "Metallica"}},
},
Genres: []*metadataPb.Genre{
{Name: "Thrash Metal"},
{Name: "Heavy Metal"},
},
Label: &metadataPb.Label{Name: "Blackened Recordings"},
}
p := NewGenericParser()
r := p.ParseTorrent(torrentData, album)
t.Logf("Artist: %s", r.Artist)
t.Logf("Album: %s", r.Album)
t.Logf("Year: %d", r.Year)
t.Logf("Type: %s", r.Type)
t.Logf("Genres: %v", r.Genres)
t.Logf("Format: %s", r.Format)
t.Logf("Source: %s", r.Source)
t.Logf("Label: %s", r.Label)
t.Logf("InfoHash: %s", r.InfoHash)
t.Logf("TrackCount: %d", r.TrackCount)
t.Logf("AudioFiles: %d", r.AudioFileCount)
t.Logf("AudioSize: %d bytes", r.TotalAudioSize)
t.Logf("HasCover: %v", r.HasCoverArt)
t.Logf("HasCue: %v", r.HasCueSheet)
t.Logf("HasLog: %v", r.HasRipLog)
t.Logf("TrackNames: %v", r.TrackNames)
t.Logf("Parsed OK: %v", r.ParsedSuccessfully)
t.Logf("Errors: %v", r.ParseErrors)
if r.Artist != "Metallica" {
t.Errorf("Artist = %q, want Metallica", r.Artist)
}
if r.Album != "72 Seasons" {
t.Errorf("Album = %q, want 72 Seasons", r.Album)
}
if r.Year != 2023 {
t.Errorf("Year = %d, want 2023", r.Year)
}
if r.Format != release.FormatFLAC {
t.Errorf("Format = %v, want FLAC", r.Format)
}
if r.AudioFileCount != 12 {
t.Errorf("AudioFileCount = %d, want 12", r.AudioFileCount)
}
if !r.HasCoverArt {
t.Error("expected HasCoverArt")
}
if !r.HasCueSheet {
t.Error("expected HasCueSheet")
}
if !r.HasRipLog {
t.Error("expected HasRipLog")
}
if r.Source != release.SourceCD {
t.Errorf("Source = %v, want CD (inferred from log)", r.Source)
}
if !r.ParsedSuccessfully {
t.Errorf("ParsedSuccessfully = false, errors: %v", r.ParseErrors)
}
}
+125
View File
@@ -0,0 +1,125 @@
package tracker
import (
"context"
"fmt"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"github.com/rs/zerolog/log"
)
type MagnetResolver struct {
client *torrent.Client
timeout time.Duration
}
func NewMagnetResolver(timeout time.Duration) (*MagnetResolver, error) {
cfg := torrent.NewDefaultClientConfig()
cfg.DataDir = ""
cfg.NoDHT = false
cfg.NoUpload = true
cfg.Seed = false
cfg.ListenPort = 0
client, err := torrent.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("creating torrent client: %w", err)
}
log.Info().Dur("timeout", timeout).Msg("magnet resolver initialized")
return &MagnetResolver{
client: client,
timeout: timeout,
}, nil
}
func (r *MagnetResolver) Resolve(magnetURI string) ([]byte, error) {
truncated := magnetURI
if len(truncated) > 80 {
truncated = truncated[:80] + "..."
}
log.Trace().Str("magnet", truncated).Msg("resolving magnet")
t, err := r.client.AddMagnet(magnetURI)
if err != nil {
return nil, fmt.Errorf("adding magnet: %w", err)
}
defer t.Drop()
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
noActiveSince := time.Now()
for {
select {
case <-t.GotInfo():
ticker.Stop()
goto resolved
case <-ctx.Done():
stats := t.Stats()
log.Warn().
Str("hash", t.InfoHash().HexString()).
Int("total_peers", stats.TotalPeers).
Int("active_peers", stats.ActivePeers).
Int("pending_peers", stats.PendingPeers).
Int("half_open_peers", stats.HalfOpenPeers).
Int("connected_seeders", stats.ConnectedSeeders).
Msg("magnet resolve timed out")
return nil, fmt.Errorf("timeout resolving magnet after %s: peers=%d active=%d seeders=%d",
r.timeout, stats.TotalPeers, stats.ActivePeers, stats.ConnectedSeeders)
case <-ticker.C:
stats := t.Stats()
log.Trace().
Str("hash", t.InfoHash().HexString()).
Int("total_peers", stats.TotalPeers).
Int("active_peers", stats.ActivePeers).
Int("connected_seeders", stats.ConnectedSeeders).
Msg("magnet resolve waiting")
if stats.ActivePeers > 0 {
noActiveSince = time.Now()
}
if stats.TotalPeers > 0 && time.Since(noActiveSince) > 15*time.Second {
log.Warn().
Str("hash", t.InfoHash().HexString()).
Int("total_peers", stats.TotalPeers).
Int("active_peers", stats.ActivePeers).
Msg("magnet has peers but none active for 15s, giving up early")
return nil, fmt.Errorf("no active peers after 15s: total=%d active=%d", stats.TotalPeers, stats.ActivePeers)
}
}
}
resolved:
info := t.Info()
log.Debug().
Str("name", info.Name).
Int("files", len(info.Files)).
Int64("size", info.TotalLength()).
Msg("magnet resolved")
mi := t.Metainfo()
data, err := bencode.Marshal(metainfo.MetaInfo{
InfoBytes: mi.InfoBytes,
Announce: mi.Announce,
AnnounceList: mi.AnnounceList,
})
if err != nil {
return nil, fmt.Errorf("marshaling torrent data: %w", err)
}
return data, nil
}
func (r *MagnetResolver) Close() {
r.client.Close()
}