Add album/track releases with audio analysis, AnalyzeAlbumRelease RPC, Docker path auto-resolution, release parsing decision tree
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user