mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
ReadFileMetadata now fills all tag fields (title, artist, album, ISRC, lyrics, genre, label, copyright, composer, comment, track/disc number) for M4A files using the new ReadM4ATags helper, matching the existing behavior for FLAC, MP3, and Ogg. scanM4AFile reads tags via ReadM4ATags instead of falling back to the filename, and applies applyDefaultLibraryMetadata for missing fields (consistent with FLAC/MP3 scan path). Remove the '&& ext != ".m4a"' guard in cover cache so M4A cover art is extracted and cached during library scans.
835 lines
23 KiB
Go
835 lines
23 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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"`
|
|
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"`
|
|
}
|
|
|
|
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,
|
|
".cue": true,
|
|
}
|
|
|
|
type libraryAudioFileInfo struct {
|
|
path string
|
|
modTime int64
|
|
}
|
|
|
|
type scannedCueFileInfo struct {
|
|
sheet *CueSheet
|
|
audioPath string
|
|
}
|
|
|
|
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
|
var files []libraryAudioFileInfo
|
|
|
|
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() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if !supportedAudioFormats[ext] {
|
|
return nil
|
|
}
|
|
|
|
files = append(files, libraryAudioFileInfo{
|
|
path: path,
|
|
modTime: info.ModTime().UnixMilli(),
|
|
})
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return files, 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
|
|
|
|
// Track audio files referenced by .cue sheets to avoid duplicates
|
|
cueReferencedAudioFiles := make(map[string]bool)
|
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
|
|
|
// First pass: scan .cue files to collect referenced audio paths
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for i, fileInfo := range audioFileInfos {
|
|
filePath := fileInfo.path
|
|
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()
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
// Handle .cue files: produce multiple track results
|
|
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)
|
|
continue
|
|
}
|
|
results = append(results, cueResults...)
|
|
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
|
continue
|
|
}
|
|
|
|
// Skip audio files that are referenced by a .cue sheet
|
|
// (they will be represented by the cue sheet's track entries instead)
|
|
if cueReferencedAudioFiles[filePath] {
|
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
|
continue
|
|
}
|
|
|
|
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
|
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) {
|
|
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
|
}
|
|
|
|
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
|
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
|
|
}
|
|
|
|
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
|
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
|
|
|
result := &LibraryScanResult{
|
|
ID: generateLibraryID(filePath),
|
|
FilePath: filePath,
|
|
ScannedAt: scanTime,
|
|
Format: strings.TrimPrefix(ext, "."),
|
|
}
|
|
|
|
if knownModTime > 0 {
|
|
result.FileModTime = knownModTime
|
|
} else if info, err := os.Stat(filePath); err == nil {
|
|
result.FileModTime = info.ModTime().UnixMilli()
|
|
}
|
|
|
|
libraryCoverCacheMu.RLock()
|
|
coverCacheDir := libraryCoverCacheDir
|
|
libraryCoverCacheMu.RUnlock()
|
|
if coverCacheDir != "" {
|
|
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, 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, displayNameHint)
|
|
default:
|
|
return scanFromFilename(filePath, displayNameHint, result)
|
|
}
|
|
}
|
|
|
|
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
if ext != "" {
|
|
return ext
|
|
}
|
|
return strings.ToLower(filepath.Ext(displayNameHint))
|
|
}
|
|
|
|
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
|
if displayNameHint != "" {
|
|
return displayNameHint
|
|
}
|
|
return filePath
|
|
}
|
|
|
|
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
|
if result.TrackName == "" {
|
|
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
|
}
|
|
if result.ArtistName == "" {
|
|
result.ArtistName = "Unknown Artist"
|
|
}
|
|
if result.AlbumName == "" {
|
|
result.AlbumName = "Unknown Album"
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
applyDefaultLibraryMetadata(filePath, "", result)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
|
metadata, err := ReadM4ATags(filePath)
|
|
if err == nil && metadata != nil {
|
|
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
|
|
if result.ReleaseDate == "" {
|
|
result.ReleaseDate = metadata.Year
|
|
}
|
|
result.Genre = metadata.Genre
|
|
}
|
|
|
|
quality, err := GetM4AQuality(filePath)
|
|
if err == nil {
|
|
result.BitDepth = quality.BitDepth
|
|
result.SampleRate = quality.SampleRate
|
|
}
|
|
|
|
applyDefaultLibraryMetadata(filePath, "", result)
|
|
return result, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
applyDefaultLibraryMetadata(filePath, "", result)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
metadata, err := ReadOggVorbisComments(filePath)
|
|
if err != nil {
|
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
|
return scanFromFilename(filePath, displayNameHint, 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
|
|
}
|
|
}
|
|
|
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
|
|
|
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 == "fd" || result.AlbumName == "self" {
|
|
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) {
|
|
return ReadAudioMetadataWithDisplayName(filePath, "")
|
|
}
|
|
|
|
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
|
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, 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
|
|
}
|
|
|
|
// 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 loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
|
existingFiles := make(map[string]int64)
|
|
if snapshotPath == "" {
|
|
return existingFiles, nil
|
|
}
|
|
|
|
file, err := os.Open(snapshotPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "\t", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
existingFiles[parts[1]] = modTime
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return existingFiles, nil
|
|
}
|
|
|
|
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (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)
|
|
}
|
|
|
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
|
|
|
libraryScanProgressMu.Lock()
|
|
libraryScanProgress = LibraryScanProgress{}
|
|
libraryScanProgressMu.Unlock()
|
|
|
|
libraryScanCancelMu.Lock()
|
|
if libraryScanCancel != nil {
|
|
close(libraryScanCancel)
|
|
}
|
|
libraryScanCancel = make(chan struct{})
|
|
cancelCh := libraryScanCancel
|
|
libraryScanCancelMu.Unlock()
|
|
|
|
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
|
if err != nil {
|
|
return "{}", err
|
|
}
|
|
currentPathSet := make(map[string]bool, len(currentFiles))
|
|
for _, fileInfo := range currentFiles {
|
|
currentPathSet[fileInfo.path] = true
|
|
}
|
|
|
|
totalFiles := len(currentFiles)
|
|
libraryScanProgressMu.Lock()
|
|
libraryScanProgress.TotalFiles = totalFiles
|
|
libraryScanProgressMu.Unlock()
|
|
|
|
// Find files to scan (new or modified)
|
|
var filesToScan []libraryAudioFileInfo
|
|
skippedCount := 0
|
|
existingCueTrackModTimes := make(map[string]int64)
|
|
for existingPath, modTime := range existingFiles {
|
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
|
baseCuePath := existingPath[:idx]
|
|
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
|
existingCueTrackModTimes[baseCuePath] = modTime
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, f := range currentFiles {
|
|
existingModTime, exists := existingFiles[f.path]
|
|
if !exists {
|
|
// For .cue files, also check if any virtual path entries exist
|
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
|
// CUE file exists in DB via virtual paths; check if modTime changed
|
|
if f.modTime == cueTrackModTime {
|
|
skippedCount++
|
|
} else {
|
|
filesToScan = append(filesToScan, f)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
filesToScan = append(filesToScan, f)
|
|
} else if f.modTime != existingModTime {
|
|
filesToScan = append(filesToScan, f)
|
|
} else {
|
|
skippedCount++
|
|
}
|
|
}
|
|
|
|
var deletedPaths []string
|
|
for existingPath := range existingFiles {
|
|
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
|
|
// check if the base .cue file still exists on disk
|
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
|
baseCuePath := existingPath[:idx]
|
|
if currentPathSet[baseCuePath] {
|
|
continue // Base .cue file still exists, not deleted
|
|
}
|
|
// Base CUE file is gone, mark virtual path as deleted
|
|
deletedPaths = append(deletedPaths, existingPath)
|
|
} else 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
|
|
}
|
|
|
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
|
errorCount := 0
|
|
|
|
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
|
for _, f := range filesToScan {
|
|
ext := strings.ToLower(filepath.Ext(f.path))
|
|
if ext == ".cue" {
|
|
sheet, err := ParseCueFile(f.path)
|
|
if err == nil && sheet.FileName != "" {
|
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
|
if audioPath != "" {
|
|
parsedCueFiles[f.path] = scannedCueFileInfo{
|
|
sheet: sheet,
|
|
audioPath: audioPath,
|
|
}
|
|
cueReferencedAudioFilesInc[audioPath] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
ext := strings.ToLower(filepath.Ext(f.path))
|
|
|
|
// Handle .cue files: produce multiple track results
|
|
if ext == ".cue" {
|
|
var cueResults []LibraryScanResult
|
|
cueInfo, ok := parsedCueFiles[f.path]
|
|
if ok {
|
|
cueResults, err = scanCueSheetForLibrary(
|
|
f.path,
|
|
cueInfo.sheet,
|
|
cueInfo.audioPath,
|
|
"",
|
|
f.modTime,
|
|
scanTime,
|
|
)
|
|
} else {
|
|
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
|
}
|
|
if err != nil {
|
|
errorCount++
|
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
|
continue
|
|
}
|
|
results = append(results, cueResults...)
|
|
continue
|
|
}
|
|
|
|
// Skip audio files referenced by .cue sheets
|
|
if cueReferencedAudioFilesInc[f.path] {
|
|
continue
|
|
}
|
|
|
|
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
}
|
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
|
}
|
|
|
|
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
|
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
|
if err != nil {
|
|
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
|
}
|
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
|
}
|