package gobackend import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" ) // LibraryScanResult represents metadata from a scanned audio file type LibraryScanResult struct { ID string `json:"id"` TrackName string `json:"trackName"` ArtistName string `json:"artistName"` AlbumName string `json:"albumName"` AlbumArtist string `json:"albumArtist,omitempty"` FilePath string `json:"filePath"` CoverPath string `json:"coverPath,omitempty"` ScannedAt string `json:"scannedAt"` FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds ISRC string `json:"isrc,omitempty"` TrackNumber int `json:"trackNumber,omitempty"` DiscNumber int `json:"discNumber,omitempty"` Duration int `json:"duration,omitempty"` ReleaseDate string `json:"releaseDate,omitempty"` BitDepth int `json:"bitDepth,omitempty"` SampleRate int `json:"sampleRate,omitempty"` Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) Genre string `json:"genre,omitempty"` Format string `json:"format,omitempty"` } type LibraryScanProgress struct { TotalFiles int `json:"total_files"` ScannedFiles int `json:"scanned_files"` CurrentFile string `json:"current_file"` ErrorCount int `json:"error_count"` ProgressPct float64 `json:"progress_pct"` IsComplete bool `json:"is_complete"` } // IncrementalScanResult contains results of an incremental library scan type IncrementalScanResult struct { Scanned []LibraryScanResult `json:"scanned"` // New or updated files DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist SkippedCount int `json:"skippedCount"` // Files that were unchanged TotalFiles int `json:"totalFiles"` // Total files in folder } var ( libraryScanProgress LibraryScanProgress libraryScanProgressMu sync.RWMutex libraryScanCancel chan struct{} libraryScanCancelMu sync.Mutex libraryCoverCacheDir string libraryCoverCacheMu sync.RWMutex ) var supportedAudioFormats = map[string]bool{ ".flac": true, ".m4a": true, ".mp3": true, ".opus": true, ".ogg": true, } func SetLibraryCoverCacheDir(cacheDir string) { libraryCoverCacheMu.Lock() libraryCoverCacheDir = cacheDir libraryCoverCacheMu.Unlock() } func ScanLibraryFolder(folderPath string) (string, error) { if folderPath == "" { return "[]", fmt.Errorf("folder path is empty") } info, err := os.Stat(folderPath) if err != nil { return "[]", fmt.Errorf("folder not found: %w", err) } if !info.IsDir() { return "[]", fmt.Errorf("path is not a folder: %s", folderPath) } libraryScanProgressMu.Lock() libraryScanProgress = LibraryScanProgress{} libraryScanProgressMu.Unlock() libraryScanCancelMu.Lock() if libraryScanCancel != nil { close(libraryScanCancel) } libraryScanCancel = make(chan struct{}) cancelCh := libraryScanCancel libraryScanCancelMu.Unlock() var audioFiles []string err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } select { case <-cancelCh: return fmt.Errorf("scan cancelled") default: } if !info.IsDir() { ext := strings.ToLower(filepath.Ext(path)) if supportedAudioFormats[ext] { audioFiles = append(audioFiles, path) } } return nil }) if err != nil { return "[]", err } totalFiles := len(audioFiles) libraryScanProgressMu.Lock() libraryScanProgress.TotalFiles = totalFiles libraryScanProgressMu.Unlock() if totalFiles == 0 { libraryScanProgressMu.Lock() libraryScanProgress.IsComplete = true libraryScanProgressMu.Unlock() return "[]", nil } GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles) results := make([]LibraryScanResult, 0, totalFiles) scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 for i, filePath := range audioFiles { select { case <-cancelCh: return "[]", fmt.Errorf("scan cancelled") default: } libraryScanProgressMu.Lock() libraryScanProgress.ScannedFiles = i + 1 libraryScanProgress.CurrentFile = filepath.Base(filePath) libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() result, err := scanAudioFile(filePath, scanTime) if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err) continue } results = append(results, *result) } libraryScanProgressMu.Lock() libraryScanProgress.ErrorCount = errorCount libraryScanProgress.IsComplete = true libraryScanProgressMu.Unlock() GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount) jsonBytes, err := json.Marshal(results) if err != nil { return "[]", fmt.Errorf("failed to marshal results: %w", err) } return string(jsonBytes), nil } func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { ext := strings.ToLower(filepath.Ext(filePath)) result := &LibraryScanResult{ ID: generateLibraryID(filePath), FilePath: filePath, ScannedAt: scanTime, Format: strings.TrimPrefix(ext, "."), } // Get file modification time if info, err := os.Stat(filePath); err == nil { result.FileModTime = info.ModTime().UnixMilli() } libraryCoverCacheMu.RLock() coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() if coverCacheDir != "" && ext != ".m4a" { coverPath, err := SaveCoverToCache(filePath, coverCacheDir) if err == nil && coverPath != "" { result.CoverPath = coverPath } } switch ext { case ".flac": return scanFLACFile(filePath, result) case ".m4a": return scanM4AFile(filePath, result) case ".mp3": return scanMP3File(filePath, result) case ".opus", ".ogg": return scanOggFile(filePath, result) default: return scanFromFilename(filePath, result) } } func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadMetadata(filePath) if err != nil { return scanFromFilename(filePath, result) } result.TrackName = metadata.Title result.ArtistName = metadata.Artist result.AlbumName = metadata.Album result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber result.DiscNumber = metadata.DiscNumber result.ReleaseDate = metadata.Date result.Genre = metadata.Genre quality, err := GetAudioQuality(filePath) if err == nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate if quality.SampleRate > 0 && quality.TotalSamples > 0 { result.Duration = int(quality.TotalSamples / int64(quality.SampleRate)) } } if result.TrackName == "" { result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) } if result.ArtistName == "" { result.ArtistName = "Unknown Artist" } if result.AlbumName == "" { result.AlbumName = "Unknown Album" } return result, nil } func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { quality, err := GetM4AQuality(filePath) if err == nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate } return scanFromFilename(filePath, result) } func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadID3Tags(filePath) if err != nil { GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err) return scanFromFilename(filePath, result) } result.TrackName = metadata.Title result.ArtistName = metadata.Artist result.AlbumName = metadata.Album result.AlbumArtist = metadata.AlbumArtist result.TrackNumber = metadata.TrackNumber result.DiscNumber = metadata.DiscNumber result.Genre = metadata.Genre if metadata.Date != "" { result.ReleaseDate = metadata.Date } else { result.ReleaseDate = metadata.Year } result.ISRC = metadata.ISRC quality, err := GetMP3Quality(filePath) if err == nil { result.SampleRate = quality.SampleRate result.BitDepth = quality.BitDepth // 0 for lossy result.Duration = quality.Duration if quality.Bitrate > 0 { result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps } } if result.TrackName == "" { result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) } if result.ArtistName == "" { result.ArtistName = "Unknown Artist" } if result.AlbumName == "" { result.AlbumName = "Unknown Album" } return result, nil } func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadOggVorbisComments(filePath) if err != nil { GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err) return scanFromFilename(filePath, result) } result.TrackName = metadata.Title result.ArtistName = metadata.Artist result.AlbumName = metadata.Album result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber result.DiscNumber = metadata.DiscNumber result.Genre = metadata.Genre result.ReleaseDate = metadata.Date quality, err := GetOggQuality(filePath) if err == nil { result.SampleRate = quality.SampleRate result.BitDepth = quality.BitDepth // 0 for lossy result.Duration = quality.Duration if quality.Bitrate > 0 { result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps } } if result.TrackName == "" { result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) } if result.ArtistName == "" { result.ArtistName = "Unknown Artist" } if result.AlbumName == "" { result.AlbumName = "Unknown Album" } return result, nil } func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) parts := strings.SplitN(filename, " - ", 2) if len(parts) == 2 { if len(parts[0]) <= 3 && isNumeric(parts[0]) { result.TrackName = parts[1] result.ArtistName = "Unknown Artist" } else { result.ArtistName = parts[0] result.TrackName = parts[1] } } else { if len(filename) > 3 && isNumeric(filename[:2]) { title := strings.TrimLeft(filename[2:], " .-") result.TrackName = title } else { result.TrackName = filename } result.ArtistName = "Unknown Artist" } dir := filepath.Dir(filePath) result.AlbumName = filepath.Base(dir) if result.AlbumName == "." || result.AlbumName == "" { result.AlbumName = "Unknown Album" } return result, nil } func isNumeric(s string) bool { for _, c := range s { if c < '0' || c > '9' { return false } } return len(s) > 0 } func generateLibraryID(filePath string) string { return fmt.Sprintf("lib_%x", hashString(filePath)) } func hashString(s string) uint32 { var hash uint32 = 5381 for _, c := range s { hash = ((hash << 5) + hash) + uint32(c) } return hash } func GetLibraryScanProgress() string { libraryScanProgressMu.RLock() defer libraryScanProgressMu.RUnlock() jsonBytes, _ := json.Marshal(libraryScanProgress) return string(jsonBytes) } func CancelLibraryScan() { libraryScanCancelMu.Lock() defer libraryScanCancelMu.Unlock() if libraryScanCancel != nil { close(libraryScanCancel) libraryScanCancel = nil } } func ReadAudioMetadata(filePath string) (string, error) { scanTime := time.Now().UTC().Format(time.RFC3339) result, err := scanAudioFile(filePath, scanTime) if err != nil { return "", err } jsonBytes, err := json.Marshal(result) if err != nil { return "", fmt.Errorf("failed to marshal result: %w", err) } return string(jsonBytes), nil } // ScanLibraryFolderIncremental performs an incremental scan of the library folder // existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) // Only files that are new or have changed modification time will be scanned func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { if folderPath == "" { return "{}", fmt.Errorf("folder path is empty") } info, err := os.Stat(folderPath) if err != nil { return "{}", fmt.Errorf("folder not found: %w", err) } if !info.IsDir() { return "{}", fmt.Errorf("path is not a folder: %s", folderPath) } // Parse existing files map existingFiles := make(map[string]int64) if existingFilesJSON != "" && existingFilesJSON != "{}" { if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err) } } GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles)) // Reset progress libraryScanProgressMu.Lock() libraryScanProgress = LibraryScanProgress{} libraryScanProgressMu.Unlock() // Setup cancellation libraryScanCancelMu.Lock() if libraryScanCancel != nil { close(libraryScanCancel) } libraryScanCancel = make(chan struct{}) cancelCh := libraryScanCancel libraryScanCancelMu.Unlock() // Collect all audio files with their mod times type fileInfo struct { path string modTime int64 } var currentFiles []fileInfo currentPathSet := make(map[string]bool) err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } select { case <-cancelCh: return fmt.Errorf("scan cancelled") default: } if !info.IsDir() { ext := strings.ToLower(filepath.Ext(path)) if supportedAudioFormats[ext] { currentFiles = append(currentFiles, fileInfo{ path: path, modTime: info.ModTime().UnixMilli(), }) currentPathSet[path] = true } } return nil }) if err != nil { return "{}", err } totalFiles := len(currentFiles) libraryScanProgressMu.Lock() libraryScanProgress.TotalFiles = totalFiles libraryScanProgressMu.Unlock() // Find files to scan (new or modified) var filesToScan []fileInfo skippedCount := 0 for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { // New file filesToScan = append(filesToScan, f) } else if f.modTime != existingModTime { // Modified file filesToScan = append(filesToScan, f) } else { // Unchanged file - skip skippedCount++ } } // Find deleted files var deletedPaths []string for existingPath := range existingFiles { if !currentPathSet[existingPath] { deletedPaths = append(deletedPaths, existingPath) } } GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n", len(filesToScan), skippedCount, len(deletedPaths)) if len(filesToScan) == 0 { libraryScanProgressMu.Lock() libraryScanProgress.ScannedFiles = totalFiles libraryScanProgress.IsComplete = true libraryScanProgress.ProgressPct = 100 libraryScanProgressMu.Unlock() result := IncrementalScanResult{ Scanned: []LibraryScanResult{}, DeletedPaths: deletedPaths, SkippedCount: skippedCount, TotalFiles: totalFiles, } jsonBytes, _ := json.Marshal(result) return string(jsonBytes), nil } // Scan the files that need scanning results := make([]LibraryScanResult, 0, len(filesToScan)) scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 for i, f := range filesToScan { select { case <-cancelCh: return "{}", fmt.Errorf("scan cancelled") default: } libraryScanProgressMu.Lock() libraryScanProgress.ScannedFiles = skippedCount + i + 1 libraryScanProgress.CurrentFile = filepath.Base(f.path) libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() result, err := scanAudioFile(f.path, scanTime) if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err) continue } results = append(results, *result) } libraryScanProgressMu.Lock() libraryScanProgress.ErrorCount = errorCount libraryScanProgress.IsComplete = true libraryScanProgress.ScannedFiles = totalFiles libraryScanProgress.ProgressPct = 100 libraryScanProgressMu.Unlock() GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n", len(results), skippedCount, len(deletedPaths), errorCount) scanResult := IncrementalScanResult{ Scanned: results, DeletedPaths: deletedPaths, SkippedCount: skippedCount, TotalFiles: totalFiles, } jsonBytes, err := json.Marshal(scanResult) if err != nil { return "{}", fmt.Errorf("failed to marshal results: %w", err) } return string(jsonBytes), nil }