Files
music-agregator/internal/analysis/analyzer.go
T

286 lines
7.1 KiB
Go

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
}