Add album/track releases with audio analysis, AnalyzeAlbumRelease RPC, Docker path auto-resolution, release parsing decision tree

This commit is contained in:
Alexander
2026-05-09 23:16:59 +02:00
parent 1e8506f146
commit 2740585261
25 changed files with 1841 additions and 125 deletions
+285
View File
@@ -0,0 +1,285 @@
package analysis
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/audio"
"homelab.lan/music-agregator/internal/database"
)
var audioExtensions = map[string]bool{
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
}
var trackNumberRegex = regexp.MustCompile(`^(\d+)[\s\-._]+`)
type ReleaseAnalyzer struct {
downloads *database.DownloadRepository
downloadFiles *database.DownloadFileRepository
albumReleases *database.AlbumReleaseRepository
trackReleases *database.TrackReleaseRepository
}
func NewReleaseAnalyzer(db *database.DB) *ReleaseAnalyzer {
return &ReleaseAnalyzer{
downloads: database.NewDownloadRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
}
}
func (a *ReleaseAnalyzer) Analyze(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
download, err := a.downloads.GetByID(ctx, downloadID)
if err != nil {
return nil, nil, fmt.Errorf("getting download: %w", err)
}
files, err := a.downloadFiles.GetByDownloadID(ctx, downloadID)
if err != nil || len(files) == 0 {
log.Info().Str("download_id", downloadID).Msg("no download files in DB, scanning filesystem")
scanned, scanErr := ScanAndHashFiles(contentPath)
if scanErr != nil {
return nil, nil, fmt.Errorf("scanning files: %w", scanErr)
}
for _, f := range scanned {
f.DownloadID = downloadID
}
if err := a.downloadFiles.CreateBatch(ctx, scanned); err != nil {
return nil, nil, fmt.Errorf("persisting scanned files: %w", err)
}
files = scanned
}
var audioFiles []*database.DownloadFile
var hasCoverArt, hasCueSheet, hasRipLog bool
for _, f := range files {
if audioExtensions["."+f.FileType] {
audioFiles = append(audioFiles, f)
}
switch f.FileType {
case "jpg", "jpeg", "png", "gif", "webp":
hasCoverArt = true
case "cue":
hasCueSheet = true
case "log":
hasRipLog = true
}
}
if len(audioFiles) == 0 {
return nil, nil, fmt.Errorf("no audio files found for download %s", downloadID)
}
var trackReleases []*database.TrackRelease
var totalSize int64
var totalDuration int
formatCounts := make(map[string]int)
var firstBitDepth, firstSampleRate, firstChannels int
var firstIsLossless bool
for i, f := range audioFiles {
fullPath := filepath.Join(contentPath, f.FilePath)
info, err := audio.Analyze(fullPath)
if err != nil {
log.Warn().Err(err).Str("path", f.FilePath).Msg("failed to analyze audio file")
info = &audio.TrackInfo{
Format: strings.ToUpper(f.FileType),
}
}
if i == 0 {
firstBitDepth = info.BitDepth
firstSampleRate = info.SampleRate
firstChannels = info.Channels
firstIsLossless = info.IsLossless
}
formatCounts[info.Format]++
totalSize += f.FileSize
totalDuration += info.DurationMs
trackNum := extractTrackNumber(f.FilePath)
title := extractTitle(f.FilePath)
tr := &database.TrackRelease{
Title: title,
TrackNumber: trackNum,
DiscNumber: 1,
Format: info.Format,
Channels: info.Channels,
FileSize: f.FileSize,
FilePath: f.FilePath,
}
if info.DurationMs > 0 {
dur := info.DurationMs
tr.DurationMs = &dur
}
if info.BitDepth > 0 {
bd := info.BitDepth
tr.BitDepth = &bd
}
if info.SampleRate > 0 {
sr := info.SampleRate
tr.SampleRate = &sr
}
if info.BitrateKbps > 0 {
br := info.BitrateKbps
tr.BitrateKbps = &br
}
trackReleases = append(trackReleases, tr)
}
dominantFormat := ""
maxCount := 0
for format, count := range formatCounts {
if count > maxCount {
dominantFormat = format
maxCount = count
}
}
var source *string
if hasRipLog {
s := "CD"
source = &s
}
release := &database.AlbumRelease{
AlbumID: download.AlbumID,
DownloadID: downloadID,
Format: dominantFormat,
Channels: firstChannels,
IsLossless: firstIsLossless,
Source: source,
TotalSize: totalSize,
TotalDurationMs: totalDuration,
TrackCount: len(audioFiles),
HasCoverArt: hasCoverArt,
HasCueSheet: hasCueSheet,
HasRipLog: hasRipLog,
Path: contentPath,
}
if firstBitDepth > 0 {
release.BitDepth = &firstBitDepth
}
if firstSampleRate > 0 {
release.SampleRate = &firstSampleRate
}
return release, trackReleases, nil
}
func (a *ReleaseAnalyzer) AnalyzeAndPersist(ctx context.Context, downloadID string, contentPath string) (*database.AlbumRelease, []*database.TrackRelease, error) {
release, trackReleases, err := a.Analyze(ctx, downloadID, contentPath)
if err != nil {
return nil, nil, err
}
if err := a.albumReleases.DeleteByDownloadID(ctx, downloadID); err != nil {
log.Warn().Err(err).Str("download_id", downloadID).Msg("failed to delete existing album release")
}
if err := a.albumReleases.Create(ctx, release); err != nil {
return nil, nil, fmt.Errorf("creating album release: %w", err)
}
for _, tr := range trackReleases {
tr.AlbumReleaseID = release.ID
}
if err := a.trackReleases.CreateBatch(ctx, trackReleases); err != nil {
return nil, nil, fmt.Errorf("creating track releases: %w", err)
}
return release, trackReleases, nil
}
func extractTrackNumber(filePath string) int {
base := filepath.Base(filePath)
matches := trackNumberRegex.FindStringSubmatch(base)
if len(matches) >= 2 {
var num int
fmt.Sscanf(matches[1], "%d", &num)
return num
}
return 0
}
func extractTitle(filePath string) string {
base := filepath.Base(filePath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
name = trackNumberRegex.ReplaceAllString(name, "")
return strings.TrimSpace(name)
}
func IsAudioExtension(ext string) bool {
return audioExtensions[ext]
}
func ScanAndHashFiles(rootPath string) ([]*database.DownloadFile, error) {
var files []*database.DownloadFile
err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
ext := strings.ToLower(filepath.Ext(path))
relPath, _ := filepath.Rel(rootPath, path)
fileType := strings.TrimPrefix(ext, ".")
if fileType == "" {
return nil
}
df := &database.DownloadFile{
FilePath: relPath,
FileSize: info.Size(),
FileType: fileType,
}
if IsAudioExtension(ext) || ext == ".cue" || ext == ".log" {
hash, err := hashFile(path)
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("failed to hash file")
} else {
df.SHA256Hash = hash
now := time.Now()
df.VerifiedAt = &now
}
}
files = append(files, df)
return nil
})
return files, err
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hashing file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+35
View File
@@ -0,0 +1,35 @@
package audio
import (
"fmt"
"path/filepath"
"strings"
)
type TrackInfo struct {
Format string
BitDepth int
SampleRate int
Channels int
DurationMs int
BitrateKbps int
IsLossless bool
}
func Analyze(filePath string) (*TrackInfo, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return analyzeFLAC(filePath)
case ".mp3":
return analyzeMP3(filePath)
case ".aac", ".m4a", ".ogg", ".wav", ".ape", ".wv", ".alac":
return &TrackInfo{
Format: strings.ToUpper(strings.TrimPrefix(ext, ".")),
IsLossless: ext == ".wav" || ext == ".ape" || ext == ".wv" || ext == ".alac",
}, nil
default:
return nil, fmt.Errorf("unsupported audio format: %s", ext)
}
}
+27
View File
@@ -0,0 +1,27 @@
package audio
import (
"fmt"
"github.com/mewkiz/flac"
)
func analyzeFLAC(filePath string) (*TrackInfo, error) {
stream, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("parsing FLAC: %w", err)
}
defer stream.Close()
info := stream.Info
durationMs := int(info.NSamples * 1000 / uint64(info.SampleRate))
return &TrackInfo{
Format: "FLAC",
BitDepth: int(info.BitsPerSample),
SampleRate: int(info.SampleRate),
Channels: int(info.NChannels),
DurationMs: durationMs,
IsLossless: true,
}, nil
}
+54
View File
@@ -0,0 +1,54 @@
package audio
import (
"fmt"
"os"
"time"
"github.com/tcolgate/mp3"
)
func analyzeMP3(filePath string) (*TrackInfo, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("opening MP3: %w", err)
}
defer f.Close()
decoder := mp3.NewDecoder(f)
var frame mp3.Frame
var skipped int
var totalDuration time.Duration
var sampleRate, channels, bitrate int
var frameCount int
for {
err := decoder.Decode(&frame, &skipped)
if err != nil {
break
}
if frameCount == 0 {
sampleRate = int(frame.Header().SampleRate())
channels = channelCount(frame.Header().ChannelMode())
bitrate = int(frame.Header().BitRate()) / 1000
}
totalDuration += frame.Duration()
frameCount++
}
return &TrackInfo{
Format: "MP3",
SampleRate: sampleRate,
Channels: channels,
DurationMs: int(totalDuration.Milliseconds()),
BitrateKbps: bitrate,
IsLossless: false,
}, nil
}
func channelCount(mode mp3.FrameChannelMode) int {
if mode == mp3.SingleChannel {
return 1
}
return 2
}
+5 -4
View File
@@ -35,10 +35,11 @@ type Config struct {
} `yaml:"indexer"`
Torrent struct {
ClientType TorrentClientType `yaml:"client_type"`
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
ClientType TorrentClientType `yaml:"client_type"`
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
ContainerName string `yaml:"container_name"`
} `yaml:"torrent"`
Metadata struct {
@@ -0,0 +1,91 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type AlbumRelease struct {
ID string
AlbumID string
DownloadID string
Format string
BitDepth *int
SampleRate *int
Channels int
IsLossless bool
Source *string
TotalSize int64
TotalDurationMs int
TrackCount int
HasCoverArt bool
HasCueSheet bool
HasRipLog bool
Path string
CreatedAt time.Time
}
type AlbumReleaseRepository struct {
pool *pgxpool.Pool
}
func NewAlbumReleaseRepository(pool *pgxpool.Pool) *AlbumReleaseRepository {
return &AlbumReleaseRepository{pool: pool}
}
func (r *AlbumReleaseRepository) Create(ctx context.Context, ar *AlbumRelease) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO album_releases (album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, created_at`,
ar.AlbumID, ar.DownloadID, ar.Format, ar.BitDepth, ar.SampleRate, ar.Channels, ar.IsLossless, ar.Source, ar.TotalSize, ar.TotalDurationMs, ar.TrackCount, ar.HasCoverArt, ar.HasCueSheet, ar.HasRipLog, ar.Path,
).Scan(&ar.ID, &ar.CreatedAt)
if err != nil {
return fmt.Errorf("creating album release: %w", err)
}
return nil
}
func (r *AlbumReleaseRepository) GetByAlbumID(ctx context.Context, albumID string) ([]*AlbumRelease, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
FROM album_releases WHERE album_id = $1 ORDER BY created_at DESC`, albumID,
)
if err != nil {
return nil, fmt.Errorf("listing album releases: %w", err)
}
defer rows.Close()
var releases []*AlbumRelease
for rows.Next() {
ar := &AlbumRelease{}
if err := rows.Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning album release: %w", err)
}
releases = append(releases, ar)
}
return releases, nil
}
func (r *AlbumReleaseRepository) GetByDownloadID(ctx context.Context, downloadID string) (*AlbumRelease, error) {
ar := &AlbumRelease{}
err := r.pool.QueryRow(ctx,
`SELECT id, album_id, download_id, format, bit_depth, sample_rate, channels, is_lossless, source, total_size, total_duration_ms, track_count, has_cover_art, has_cue_sheet, has_rip_log, path, created_at
FROM album_releases WHERE download_id = $1`, downloadID,
).Scan(&ar.ID, &ar.AlbumID, &ar.DownloadID, &ar.Format, &ar.BitDepth, &ar.SampleRate, &ar.Channels, &ar.IsLossless, &ar.Source, &ar.TotalSize, &ar.TotalDurationMs, &ar.TrackCount, &ar.HasCoverArt, &ar.HasCueSheet, &ar.HasRipLog, &ar.Path, &ar.CreatedAt)
if err != nil {
return nil, fmt.Errorf("getting album release by download: %w", err)
}
return ar, nil
}
func (r *AlbumReleaseRepository) DeleteByDownloadID(ctx context.Context, downloadID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM album_releases WHERE download_id = $1`, downloadID)
if err != nil {
return fmt.Errorf("deleting album release by download: %w", err)
}
return nil
}
+12
View File
@@ -141,6 +141,18 @@ func (r *DownloadRepository) GetActive(ctx context.Context) ([]*Download, error)
return downloads, nil
}
func (r *DownloadRepository) GetByID(ctx context.Context, id string) (*Download, error) {
d := &Download{}
err := r.pool.QueryRow(ctx,
`SELECT id, torrent_id, album_id, format, quality, state, qbit_hash, save_path, error_message, queued_at, started_at, completed_at, created_at, updated_at
FROM downloads WHERE id = $1`, id,
).Scan(&d.ID, &d.TorrentID, &d.AlbumID, &d.Format, &d.Quality, &d.State, &d.QbitHash, &d.SavePath, &d.ErrorMessage, &d.QueuedAt, &d.StartedAt, &d.CompletedAt, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("getting download by id: %w", err)
}
return d, nil
}
func (r *DownloadRepository) HasAlbumInQuality(ctx context.Context, albumID string, format string, quality string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
@@ -0,0 +1,79 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type TrackRelease struct {
ID string
AlbumReleaseID string
TrackID *string
DownloadFileID *string
Title string
TrackNumber int
DiscNumber int
DurationMs *int
Format string
BitDepth *int
SampleRate *int
Channels int
BitrateKbps *int
FileSize int64
FilePath string
CreatedAt time.Time
}
type TrackReleaseRepository struct {
pool *pgxpool.Pool
}
func NewTrackReleaseRepository(pool *pgxpool.Pool) *TrackReleaseRepository {
return &TrackReleaseRepository{pool: pool}
}
func (r *TrackReleaseRepository) Create(ctx context.Context, tr *TrackRelease) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO track_releases (album_release_id, track_id, download_file_id, title, track_number, disc_number, duration_ms, format, bit_depth, sample_rate, channels, bitrate_kbps, file_size, file_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at`,
tr.AlbumReleaseID, tr.TrackID, tr.DownloadFileID, tr.Title, tr.TrackNumber, tr.DiscNumber, tr.DurationMs, tr.Format, tr.BitDepth, tr.SampleRate, tr.Channels, tr.BitrateKbps, tr.FileSize, tr.FilePath,
).Scan(&tr.ID, &tr.CreatedAt)
if err != nil {
return fmt.Errorf("creating track release: %w", err)
}
return nil
}
func (r *TrackReleaseRepository) CreateBatch(ctx context.Context, tracks []*TrackRelease) error {
for _, tr := range tracks {
if err := r.Create(ctx, tr); err != nil {
return err
}
}
return nil
}
func (r *TrackReleaseRepository) GetByAlbumReleaseID(ctx context.Context, albumReleaseID string) ([]*TrackRelease, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, album_release_id, track_id, download_file_id, title, track_number, disc_number, duration_ms, format, bit_depth, sample_rate, channels, bitrate_kbps, file_size, file_path, created_at
FROM track_releases WHERE album_release_id = $1 ORDER BY disc_number, track_number`, albumReleaseID,
)
if err != nil {
return nil, fmt.Errorf("listing track releases: %w", err)
}
defer rows.Close()
var tracks []*TrackRelease
for rows.Next() {
tr := &TrackRelease{}
if err := rows.Scan(&tr.ID, &tr.AlbumReleaseID, &tr.TrackID, &tr.DownloadFileID, &tr.Title, &tr.TrackNumber, &tr.DiscNumber, &tr.DurationMs, &tr.Format, &tr.BitDepth, &tr.SampleRate, &tr.Channels, &tr.BitrateKbps, &tr.FileSize, &tr.FilePath, &tr.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning track release: %w", err)
}
tracks = append(tracks, tr)
}
return tracks, nil
}
+6 -2
View File
@@ -19,8 +19,8 @@ type MusicAgregatorServer struct {
pb.UnimplementedMusicAgregatorServiceServer
}
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, db *database.DB) (*MusicAgregatorServer, error) {
service, err := NewMusicAgregatorService(cfg, riverClient, torrentClient, db)
func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper, db *database.DB) (*MusicAgregatorServer, error) {
service, err := NewMusicAgregatorService(cfg, riverClient, torrentClient, pathMapper, db)
if err != nil {
log.Err(err).Msg("failed to create MusicAgregatorService")
return nil, err
@@ -46,6 +46,10 @@ func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.Monitor
return s.service.MonitorAlbum(ctx, req)
}
func (s *MusicAgregatorServer) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
return s.service.AnalyzeAlbumRelease(ctx, req)
}
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
pb.RegisterMusicAgregatorServiceServer(server, s)
}
+151 -5
View File
@@ -15,6 +15,7 @@ import (
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/indexer"
@@ -38,13 +39,17 @@ type MusicAgregatorService struct {
torrentClient torrent.TorrentClient
magnetResolver torrentParser.Resolver
riverClient *river.Client[pgx.Tx]
pathMapper *torrent.PathMapper
torrents *database.TorrentRepository
downloads *database.DownloadRepository
artists *database.ArtistRepository
downloadFiles *database.DownloadFileRepository
albumReleases *database.AlbumReleaseRepository
trackReleases *database.TrackReleaseRepository
analyzer *analysis.ReleaseAnalyzer
}
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, db *database.DB) (*MusicAgregatorService, error) {
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper, db *database.DB) (*MusicAgregatorService, error) {
idx, err := indexer.NewIndexerService(cfg, riverClient, nil)
if err != nil {
log.Err(err).Msg("failed to create IndexerService")
@@ -70,10 +75,14 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
pathMapper: pathMapper,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
analyzer: analysis.NewReleaseAnalyzer(db),
}, nil
}
@@ -83,6 +92,7 @@ func NewMusicAgregatorServiceWithDeps(
torrentClient torrent.TorrentClient,
magnetResolver torrentParser.Resolver,
riverClient *river.Client[pgx.Tx],
pathMapper *torrent.PathMapper,
db *database.DB,
) *MusicAgregatorService {
return &MusicAgregatorService{
@@ -91,10 +101,14 @@ func NewMusicAgregatorServiceWithDeps(
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
pathMapper: pathMapper,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
analyzer: analysis.NewReleaseAnalyzer(db),
}
}
@@ -204,6 +218,15 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
return nil, fmt.Errorf("album not found: %w", err)
}
info, err := service.buildAlbumInfo(ctx, dbAlbum)
if err != nil {
return nil, err
}
return &pb.GetAlbumResponse{Info: info}, nil
}
func (service *MusicAgregatorService) buildAlbumInfo(ctx context.Context, dbAlbum *database.Album) (*pb.AlbumInfo, error) {
metadataAlbum, err := service.metadata.GetAlbum(ctx, dbAlbum.ExternalID)
if err != nil {
log.Error().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get album from metadata")
@@ -299,10 +322,128 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
tracks = append(tracks, td)
}
return &pb.GetAlbumResponse{
info := &pb.AlbumInfo{
Album: album,
Tracks: tracks,
}, nil
}
albumReleases, err := service.albumReleases.GetByAlbumID(ctx, dbAlbum.ID)
if err == nil && len(albumReleases) > 0 {
ar := albumReleases[0]
releaseDetail := &pb.AlbumReleaseDetail{
Id: ar.ID,
Format: ar.Format,
Channels: int32(ar.Channels),
IsLossless: ar.IsLossless,
TotalSize: ar.TotalSize,
TotalDurationMs: int32(ar.TotalDurationMs),
TrackCount: int32(ar.TrackCount),
HasCoverArt: ar.HasCoverArt,
HasCueSheet: ar.HasCueSheet,
HasRipLog: ar.HasRipLog,
Path: ar.Path,
}
if ar.BitDepth != nil {
releaseDetail.BitDepth = int32(*ar.BitDepth)
}
if ar.SampleRate != nil {
releaseDetail.SampleRate = int32(*ar.SampleRate)
}
if ar.Source != nil {
releaseDetail.Source = *ar.Source
}
trackReleases, err := service.trackReleases.GetByAlbumReleaseID(ctx, ar.ID)
if err == nil {
for _, tr := range trackReleases {
trd := &pb.TrackReleaseDetail{
Id: tr.ID,
Title: tr.Title,
TrackNumber: int32(tr.TrackNumber),
DiscNumber: int32(tr.DiscNumber),
Format: tr.Format,
Channels: int32(tr.Channels),
FileSize: tr.FileSize,
FilePath: tr.FilePath,
}
if tr.TrackID != nil {
trd.TrackId = *tr.TrackID
}
if tr.DurationMs != nil {
trd.DurationMs = int32(*tr.DurationMs)
}
if tr.BitDepth != nil {
trd.BitDepth = int32(*tr.BitDepth)
}
if tr.SampleRate != nil {
trd.SampleRate = int32(*tr.SampleRate)
}
if tr.BitrateKbps != nil {
trd.BitrateKbps = int32(*tr.BitrateKbps)
}
releaseDetail.Tracks = append(releaseDetail.Tracks, trd)
}
}
info.Release = releaseDetail
}
return info, nil
}
func (service *MusicAgregatorService) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
dbAlbum, err := service.metadata.GetAlbumByID(ctx, req.GetAlbumId())
if err != nil {
return nil, fmt.Errorf("album not found: %w", err)
}
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
if err != nil || len(downloads) == 0 {
return nil, fmt.Errorf("no downloads found for album")
}
var download *database.Download
for _, d := range downloads {
if d.State == "completed" || d.State == "seeding" {
download = d
break
}
}
if download == nil {
return nil, fmt.Errorf("no completed download found for album")
}
contentPath := ""
existingRelease, err := service.albumReleases.GetByDownloadID(ctx, download.ID)
if err == nil {
contentPath = existingRelease.Path
}
if contentPath == "" && download.QbitHash != "" {
torrents, err := service.torrentClient.Find(torrent.FindOptions{Hash: download.QbitHash})
if err == nil && len(torrents) > 0 {
contentPath = torrents[0].ContentPath
if service.pathMapper != nil {
contentPath = service.pathMapper.ToHost(contentPath)
}
}
}
if contentPath == "" {
return nil, fmt.Errorf("cannot determine content path for download")
}
_, _, err = service.analyzer.AnalyzeAndPersist(ctx, download.ID, contentPath)
if err != nil {
return nil, fmt.Errorf("analyzing release: %w", err)
}
info, err := service.buildAlbumInfo(ctx, dbAlbum)
if err != nil {
return nil, err
}
return &pb.AnalyzeAlbumReleaseResponse{Info: info}, nil
}
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
@@ -475,8 +616,13 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
}
}
savePath := ""
if service.pathMapper != nil {
savePath = service.pathMapper.ContainerDownloadPath()
}
if strings.HasPrefix(best.item.DownloadLink, "magnet:") {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink); err != nil {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink, savePath); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add magnet to client")
return err
}
@@ -488,7 +634,7 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
Filename: best.rel.Album + ".torrent",
Data: best.torrentData,
}); err != nil {
}, savePath); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
return err
}
+3 -2
View File
@@ -43,6 +43,7 @@ type TorrentClient interface {
Login(username string, password string) (string, error)
List() ([]TorrentInfo, error)
Find(opts FindOptions) ([]TorrentInfo, error)
AddTorrent(file TorrentFile) error
AddMagnet(magnetURI string) error
AddTorrent(file TorrentFile, savePath string) error
AddMagnet(magnetURI string, savePath string) error
DefaultSavePath() (string, error)
}
+88
View File
@@ -0,0 +1,88 @@
package torrent
import (
"context"
"fmt"
"strings"
"time"
dockerclient "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
type PathMapper struct {
containerPath string
hostPath string
}
func NewPathMapper(containerName string, torrentClient TorrentClient) (*PathMapper, error) {
if containerName == "" {
savePath, err := torrentClient.DefaultSavePath()
if err != nil {
return nil, fmt.Errorf("getting default save path: %w", err)
}
log.Info().Str("path", savePath).Msg("no container configured, using direct path")
return &PathMapper{containerPath: savePath, hostPath: savePath}, nil
}
savePath, err := torrentClient.DefaultSavePath()
if err != nil {
return nil, fmt.Errorf("getting default save path: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("creating docker client: %w", err)
}
defer cli.Close()
inspect, err := cli.ContainerInspect(ctx, containerName)
if err != nil {
return nil, fmt.Errorf("inspecting container %s: %w", containerName, err)
}
hostPath := ""
for _, mount := range inspect.Mounts {
if mount.Destination == savePath {
hostPath = mount.Source
break
}
}
if hostPath == "" {
return nil, fmt.Errorf("no mount found for %s in container %s", savePath, containerName)
}
log.Info().
Str("container", containerName).
Str("container_path", savePath).
Str("host_path", hostPath).
Msg("resolved download path mapping")
return &PathMapper{containerPath: savePath, hostPath: hostPath}, nil
}
func (m *PathMapper) ToHost(containerPath string) string {
if m.containerPath == m.hostPath {
return containerPath
}
return strings.Replace(containerPath, m.containerPath, m.hostPath, 1)
}
func (m *PathMapper) ToContainer(hostPath string) string {
if m.containerPath == m.hostPath {
return hostPath
}
return strings.Replace(hostPath, m.hostPath, m.containerPath, 1)
}
func (m *PathMapper) HostDownloadPath() string {
return m.hostPath
}
func (m *PathMapper) ContainerDownloadPath() string {
return m.containerPath
}
+38 -4
View File
@@ -173,8 +173,8 @@ func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
return result
}
func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent file")
func (c *QbittorrentClient) AddTorrent(file TorrentFile, savePath string) error {
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Str("save_path", savePath).Msg("qbittorrent adding torrent file")
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
@@ -190,6 +190,12 @@ func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
return fmt.Errorf("writing torrent data: %w", err)
}
if savePath != "" {
if err := writer.WriteField("savepath", savePath); err != nil {
return fmt.Errorf("writing savepath field: %w", err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("closing multipart writer: %w", err)
}
@@ -205,14 +211,17 @@ func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
return c.doAdd(req, file.Filename)
}
func (c *QbittorrentClient) AddMagnet(magnetURI string) error {
func (c *QbittorrentClient) AddMagnet(magnetURI string, savePath string) error {
truncated := magnetURI
if len(truncated) > 80 {
truncated = truncated[:80] + "..."
}
log.Trace().Str("magnet", truncated).Msg("qbittorrent adding magnet")
log.Trace().Str("magnet", truncated).Str("save_path", savePath).Msg("qbittorrent adding magnet")
data := url.Values{"urls": {magnetURI}}
if savePath != "" {
data.Set("savepath", savePath)
}
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", strings.NewReader(data.Encode()))
if err != nil {
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
@@ -303,3 +312,28 @@ func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo {
Availability: t.Availability,
}
}
func (c *QbittorrentClient) DefaultSavePath() (string, error) {
req, err := http.NewRequest("GET", c.baseURL+"/api/v2/app/defaultSavePath", nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("requesting default save path: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("default save path returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
return strings.TrimSpace(string(body)), nil
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error)
return nil, fmt.Errorf("either torrent_data or download_url must be provided")
}
if err := service.client.AddTorrent(file); err != nil {
if err := service.client.AddTorrent(file, ""); err != nil {
return nil, err
}
+25 -70
View File
@@ -2,19 +2,13 @@ package workers
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
"homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/torrent"
)
@@ -32,7 +26,11 @@ type PollDownloadWorker struct {
TorrentClient torrent.TorrentClient
Downloads *database.DownloadRepository
DownloadFiles *database.DownloadFileRepository
AlbumReleases *database.AlbumReleaseRepository
TrackReleases *database.TrackReleaseRepository
RiverClient *river.Client[pgx.Tx]
PathMapper *torrent.PathMapper
Analyzer *analysis.ReleaseAnalyzer
}
func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownloadArgs]) error {
@@ -77,14 +75,24 @@ func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownlo
func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadArgs, t torrent.TorrentInfo) error {
log.Info().Str("hash", args.TorrentHash).Str("path", t.ContentPath).Msg("download completed")
if err := w.Downloads.SetCompleted(ctx, args.DownloadID, t.SavePath); err != nil {
contentPath := t.ContentPath
if w.PathMapper != nil {
contentPath = w.PathMapper.ToHost(contentPath)
}
savePath := t.SavePath
if w.PathMapper != nil {
savePath = w.PathMapper.ToHost(savePath)
}
if err := w.Downloads.SetCompleted(ctx, args.DownloadID, savePath); err != nil {
log.Error().Err(err).Msg("failed to update download as completed")
return err
}
files, err := scanAndHashFiles(t.ContentPath)
files, err := analysis.ScanAndHashFiles(contentPath)
if err != nil {
log.Error().Err(err).Str("path", t.ContentPath).Msg("failed to scan downloaded files")
log.Error().Err(err).Str("path", contentPath).Msg("failed to scan downloaded files")
return nil
}
@@ -102,6 +110,13 @@ func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadA
Int("files", len(files)).
Msg("download files scanned and hashed")
if w.Analyzer != nil {
_, _, err := w.Analyzer.AnalyzeAndPersist(ctx, args.DownloadID, contentPath)
if err != nil {
log.Error().Err(err).Msg("failed to analyze release")
}
}
return nil
}
@@ -148,63 +163,3 @@ func (w *PollDownloadWorker) RecoverOrphanedDownloads(ctx context.Context) {
}
}
}
var audioExtensions = map[string]bool{
".flac": true, ".mp3": true, ".aac": true, ".m4a": true,
".ape": true, ".wv": true, ".ogg": true, ".wav": true, ".alac": true,
}
func scanAndHashFiles(rootPath string) ([]*database.DownloadFile, error) {
var files []*database.DownloadFile
err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
ext := strings.ToLower(filepath.Ext(path))
relPath, _ := filepath.Rel(rootPath, path)
fileType := strings.TrimPrefix(ext, ".")
if fileType == "" {
return nil
}
df := &database.DownloadFile{
FilePath: relPath,
FileSize: info.Size(),
FileType: fileType,
}
if audioExtensions[ext] || ext == ".cue" || ext == ".log" {
hash, err := hashFile(path)
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("failed to hash file")
} else {
df.SHA256Hash = hash
now := time.Now()
df.VerifiedAt = &now
}
}
files = append(files, df)
return nil
})
return files, err
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hashing file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}