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
|
||||
}
|
||||
Reference in New Issue
Block a user