mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-03 01:28:37 +02:00
514 lines
13 KiB
Go
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"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
|
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
|
|
}
|