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 }