286 lines
7.1 KiB
Go
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
|
|
}
|