mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
593 lines
16 KiB
Go
593 lines
16 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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,
|
|
}
|
|
|
|
type libraryAudioFileInfo struct {
|
|
path string
|
|
modTime int64
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
audioFiles := make([]string, 0, len(audioFileInfos))
|
|
for _, fileInfo := range audioFileInfos {
|
|
audioFiles = append(audioFiles, fileInfo.path)
|
|
}
|
|
|
|
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, "."),
|
|
}
|
|
|
|
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 applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
|
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"
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
}
|
|
|
|
applyDefaultLibraryMetadata(filePath, result)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
applyDefaultLibraryMetadata(filePath, result)
|
|
|
|
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)
|
|
}
|
|
|
|
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))
|
|
|
|
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
|
|
|
|
for _, f := range currentFiles {
|
|
existingModTime, exists := existingFiles[f.path]
|
|
if !exists {
|
|
filesToScan = append(filesToScan, f)
|
|
} else if f.modTime != existingModTime {
|
|
filesToScan = append(filesToScan, f)
|
|
} else {
|
|
skippedCount++
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|