Files
SpotiFLAC-Mobile/go_backend/library_scan.go
T

514 lines
13 KiB
Go

package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
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"`
TotalTracks int `json:"totalTracks,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
TotalDiscs int `json:"totalDiscs,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"` // average kbps for both lossless and lossy audio
Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Format string `json:"format,omitempty"`
MetadataFromFilename bool `json:"metadataFromFilename,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"`
}
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,
".mp4": true,
".aac": true,
".mp3": true,
".opus": true,
".ogg": true,
".ape": true,
".wv": true,
".mpc": true,
".wav": true,
".aiff": true,
".aif": true,
".cue": true,
}
type libraryAudioFileInfo struct {
path string
modTime int64
size int64
}
type scannedCueFileInfo struct {
sheet *CueSheet
audioPath string
}
type libraryScanTask struct {
index int
info libraryAudioFileInfo
}
type libraryScanTaskResult struct {
index int
path string
results []LibraryScanResult
err error
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
return true
}
for ext := range supportedAudioFormats {
if strings.HasSuffix(name, ".partial"+ext) {
return true
}
}
return false
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if entry.IsDir() {
return nil
}
if isLibraryStagingFile(path) {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !supportedAudioFormats[ext] {
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
size: info.Size(),
})
return nil
})
if err != nil {
return nil, err
}
return files, nil
}
func libraryAudioCoverCacheKey(info libraryAudioFileInfo) string {
return fmt.Sprintf("%s|%d|%d", info.path, info.size, info.modTime)
}
func libraryScanWorkerCount(taskCount int) int {
if taskCount < 16 {
return 1
}
workers := runtime.NumCPU()
if workers > 4 {
workers = 4
}
if workers < 2 {
workers = 2
}
if workers > taskCount {
workers = taskCount
}
return workers
}
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = scannedFiles
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
if totalFiles > 0 {
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
}
libraryScanProgressMu.Unlock()
}
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
if len(tasks) == 0 {
return resultsByIndex, 0, nil
}
workers := libraryScanWorkerCount(len(tasks))
if workers <= 1 {
errorCount := 0
for _, task := range tasks {
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
task.info.path,
"",
libraryAudioCoverCacheKey(task.info),
scanTime,
task.info.modTime,
)
*completed++
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
continue
}
resultsByIndex[task.index] = []LibraryScanResult{*result}
}
return resultsByIndex, errorCount, nil
}
taskCh := make(chan libraryScanTask)
resultCh := make(chan libraryScanTaskResult, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range taskCh {
select {
case <-cancelCh:
return
default:
}
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
task.info.path,
"",
libraryAudioCoverCacheKey(task.info),
scanTime,
task.info.modTime,
)
taskResult := libraryScanTaskResult{
index: task.index,
path: task.info.path,
err: err,
}
if err == nil && result != nil {
taskResult.results = []LibraryScanResult{*result}
}
select {
case <-cancelCh:
return
case resultCh <- taskResult:
}
}
}()
}
go func() {
defer close(taskCh)
for _, task := range tasks {
select {
case <-cancelCh:
return
case taskCh <- task:
}
}
}()
go func() {
wg.Wait()
close(resultCh)
}()
errorCount := 0
for taskResult := range resultCh {
*completed++
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
if taskResult.err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
continue
}
resultsByIndex[taskResult.index] = taskResult.results
}
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
return resultsByIndex, errorCount, nil
}
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()
audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "[]", err
}
totalFiles := len(audioFileInfos)
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
cueReferencedAudioFiles := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, fileInfo := range audioFileInfos {
filePath := fileInfo.path
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
sheet, err := ParseCueFile(filePath)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
if audioPath != "" {
parsedCueFiles[filePath] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFiles[audioPath] = true
}
}
}
}
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
audioTasks := make([]libraryScanTask, 0, totalFiles)
completedFiles := 0
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
default:
}
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[filePath]
if ok {
cueResults, err = scanCueSheetForLibrary(
filePath,
cueInfo.sheet,
cueInfo.audioPath,
"",
fileInfo.modTime,
"",
scanTime,
)
} else {
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
}
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
continue
}
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
if cueReferencedAudioFiles[filePath] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
}
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "[]", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range audioFileInfos {
results = append(results, resultsByIndex[i]...)
}
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 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) {
return ReadAudioMetadataWithDisplayName(filePath, "")
}
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
}
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
filePath,
displayNameHint,
coverCacheKey,
scanTime,
0,
)
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
}