From f1eef476005d13a75268723dfa68a11cae1847f5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 12 Mar 2026 03:36:48 +0700 Subject: [PATCH] refactor: optimize SAF metadata reading, CUE sibling resolution, and startup initialization - Add fast-path SAF metadata reading via /proc/self/fd with displayNameHint support, falling back to temp copy - Replace repeated findFile() CUE audio sibling lookups with cached case-insensitive directory listing - Cache parsed CUE sheets to avoid redundant parsing during library scans - Optimize incremental scan CUE modTime lookup from O(N*M) to O(N+M) - Defer local library provider loading until localLibraryEnabled setting is true - Replace O(n) track+artist history lookup with O(1) map-based lookup - Delay startup maintenance tasks by 2s to reduce launch-time contention --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 279 +++++++++++------- go_backend/audio_metadata.go | 13 +- go_backend/cue_parser.go | 34 ++- go_backend/exports.go | 4 + go_backend/library_scan.go | 169 +++++++---- lib/main.dart | 40 ++- lib/providers/download_queue_provider.dart | 96 +++--- 7 files changed, 429 insertions(+), 206 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 5d5650da..50e6eda0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -703,6 +703,80 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun buildUriDisplayName( + uri: Uri, + displayNameHint: String? = null, + fallbackExt: String? = null, + ): String { + val explicitName = displayNameHint?.trim().orEmpty() + if (explicitName.isNotEmpty()) return explicitName + + val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } + val uriName = uri.lastPathSegment + val resolvedName = (docName ?: uriName ?: "").trim() + if (resolvedName.isNotEmpty()) return resolvedName + + val ext = when { + fallbackExt.isNullOrBlank().not() -> fallbackExt + isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt) + else -> "" + } + return if (ext.isNullOrBlank()) "audio" else "audio$ext" + } + + private fun readAudioMetadataFromUri( + uri: Uri, + displayNameHint: String? = null, + fallbackExt: String? = null, + ): JSONObject? { + val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) + + try { + contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val directPath = "/proc/self/fd/${pfd.fd}" + val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName) + if (metadataJson.isNotBlank()) { + val obj = JSONObject(metadataJson) + if (!obj.has("error")) { + return obj + } + } + } + } catch (e: Exception) { + android.util.Log.d( + "SpotiFLAC", + "Direct SAF metadata read fallback for $uri: ${e.message}", + ) + } + + val tempPath = try { + copyUriToTemp(uri, fallbackExt) + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "SAF metadata fallback copy failed for $uri: ${e.message}", + ) + null + } ?: return null + + try { + val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName) + if (metadataJson.isBlank()) return null + val obj = JSONObject(metadataJson) + return if (obj.has("error")) null else obj + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "SAF metadata temp read failed for $uri: ${e.message}", + ) + return null + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { val srcFile = File(srcPath) if (!srcFile.exists()) return false @@ -873,6 +947,66 @@ class MainActivity: FlutterFragmentActivity() { return null } + private val cueSiblingAudioExtensions = listOf( + ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a" + ) + + private fun getSafChildFileLookup( + dir: DocumentFile, + cache: MutableMap>, + ): Map { + val dirKey = dir.uri.toString() + return cache.getOrPut(dirKey) { + try { + buildMap { + for (child in dir.listFiles()) { + if (!child.isFile) continue + val childName = child.name?.trim().orEmpty() + if (childName.isBlank()) continue + put(childName.lowercase(Locale.ROOT), child) + } + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed to build SAF child lookup for $dirKey: ${e.message}", + ) + emptyMap() + } + } + } + + private fun resolveCueAudioSibling( + parentDir: DocumentFile, + cueName: String, + audioFileName: String?, + childLookupCache: MutableMap>, + ): DocumentFile? { + val childLookup = getSafChildFileLookup(parentDir, childLookupCache) + + val directMatch = audioFileName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.substringAfterLast("/") + ?.substringAfterLast("\\") + ?.lowercase(Locale.ROOT) + ?.let(childLookup::get) + if (directMatch != null) { + return directMatch + } + + val cueBaseName = cueName.substringBeforeLast('.').trim() + if (cueBaseName.isBlank()) { + return null + } + + val cueBaseKey = cueBaseName.lowercase(Locale.ROOT) + for (ext in cueSiblingAudioExtensions) { + childLookup["$cueBaseKey$ext"]?.let { return it } + } + return null + } + private fun scanSafTree(treeUriStr: String): String { if (treeUriStr.isBlank()) return "[]" @@ -891,6 +1025,7 @@ class MainActivity: FlutterFragmentActivity() { // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() + val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 val queue: ArrayDeque> = ArrayDeque() @@ -1000,23 +1135,12 @@ class MainActivity: FlutterFragmentActivity() { val audioFileName = extractCueAudioFileName(tempCuePath) // Find the referenced audio file as a sibling in the same SAF directory - var audioDoc: DocumentFile? = null - if (!audioFileName.isNullOrBlank()) { - audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } - } - - // Fallback: try common audio extensions with the CUE base name - if (audioDoc == null) { - val cueBaseName = cueName.substringBeforeLast('.') - val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") - for (ext in commonExts) { - audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } - if (audioDoc != null) break - // Try uppercase - audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } - if (audioDoc != null) break - } - } + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) if (audioDoc == null) { android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName") @@ -1111,35 +1235,17 @@ class MainActivity: FlutterFragmentActivity() { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val tempPath = try { - copyUriToTemp(doc.uri, fallbackExt) - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "SAF scan: failed to copy ${doc.uri}: ${e.message}", - ) - null - } - if (tempPath == null) { + val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) + if (metadataObj == null) { errors++ } else { try { - val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) - if (metadataJson.isNotBlank()) { - val obj = JSONObject(metadataJson) - val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } - obj.put("filePath", doc.uri.toString()) - obj.put("fileModTime", lastModified) - results.put(obj) - } else { - errors++ - } + val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } + metadataObj.put("filePath", doc.uri.toString()) + metadataObj.put("fileModTime", lastModified) + results.put(metadataObj) } catch (_: Exception) { errors++ - } finally { - try { - File(tempPath).delete() - } catch (_: Exception) {} } } @@ -1214,6 +1320,7 @@ class MainActivity: FlutterFragmentActivity() { val unchangedCueFiles = mutableListOf>() val currentUris = mutableSetOf() val visitedDirUris = mutableSetOf() + val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 // Build a map of CUE base URIs -> existing virtual track URIs from the database. @@ -1398,22 +1505,12 @@ class MainActivity: FlutterFragmentActivity() { val audioFileName = extractCueAudioFileName(tempCuePath) // Find the referenced audio file as a sibling in the same SAF directory - var audioDoc: DocumentFile? = null - if (!audioFileName.isNullOrBlank()) { - audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } - } - - // Fallback: try common audio extensions with the CUE base name - if (audioDoc == null) { - val cueBaseName = cueName.substringBeforeLast('.') - val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") - for (ext in commonExts) { - audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } - if (audioDoc != null) break - audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } - if (audioDoc != null) break - } - } + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) if (audioDoc == null) { android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName") @@ -1501,24 +1598,13 @@ class MainActivity: FlutterFragmentActivity() { tempCue = copyUriToTemp(cueDoc.uri, ".cue") if (tempCue != null) { val audioFileName = extractCueAudioFileName(tempCue) - var audioDoc: DocumentFile? = null - if (!audioFileName.isNullOrBlank()) { - audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } - } - // Fallback: try common extensions with CUE base name - if (audioDoc == null) { - val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } - val cueBaseName = cueName.substringBeforeLast('.') - if (cueBaseName.isNotBlank()) { - val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") - for (ext in commonExts) { - audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } - if (audioDoc != null) break - audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } - if (audioDoc != null) break - } - } - } + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) if (audioDoc != null) { cueReferencedAudioUris.add(audioDoc.uri.toString()) } @@ -1566,36 +1652,18 @@ class MainActivity: FlutterFragmentActivity() { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val tempPath = try { - copyUriToTemp(doc.uri, fallbackExt) - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "SAF incremental scan: failed to copy ${doc.uri}: ${e.message}", - ) - null - } - if (tempPath == null) { + val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) + if (metadataObj == null) { errors++ } else { try { - val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) - if (metadataJson.isNotBlank()) { - val obj = JSONObject(metadataJson) - val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } - obj.put("filePath", doc.uri.toString()) - obj.put("fileModTime", safeLastModified) - obj.put("lastModified", safeLastModified) - results.put(obj) - } else { - errors++ - } + val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } + metadataObj.put("filePath", doc.uri.toString()) + metadataObj.put("fileModTime", safeLastModified) + metadataObj.put("lastModified", safeLastModified) + results.put(metadataObj) } catch (_: Exception) { errors++ - } finally { - try { - File(tempPath).delete() - } catch (_: Exception) {} } } @@ -3116,13 +3184,10 @@ class MainActivity: FlutterFragmentActivity() { try { if (filePath.startsWith("content://")) { val uri = Uri.parse(filePath) - val tempPath = copyUriToTemp(uri) - ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" - try { - Gobackend.readAudioMetadataJSON(tempPath) - } finally { - try { File(tempPath).delete() } catch (_: Exception) {} - } + val metadata = readAudioMetadataFromUri(uri) + ?: return@withContext """{"error":"Failed to read SAF audio metadata"}""" + metadata.put("filePath", filePath) + metadata.toString() } else { Gobackend.readAudioMetadataJSON(filePath) } diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index faf4ccf2..e1e15925 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -1566,7 +1566,14 @@ func base64StdDecode(dst, src []byte) (int, error) { } func extractAnyCoverArt(filePath string) ([]byte, string, error) { + return extractAnyCoverArtWithHint(filePath, "") +} + +func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) { ext := strings.ToLower(filepath.Ext(filePath)) + if ext == "" { + ext = strings.ToLower(filepath.Ext(displayNameHint)) + } switch ext { case ".flac": @@ -1595,6 +1602,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) { } func SaveCoverToCache(filePath, cacheDir string) (string, error) { + return SaveCoverToCacheWithHint(filePath, "", cacheDir) +} + +func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) { cacheKey := filePath if stat, err := os.Stat(filePath); err == nil { cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) @@ -1611,7 +1622,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) { return pngPath, nil } - imageData, mimeType, err := extractAnyCoverArt(filePath) + imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint) if err != nil { return "", err } diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index a938be2a..914d3f4d 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -430,7 +430,15 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) { // entries, one per track. This is used by the library scanner to populate the // library with individual track entries from a single CUE+FLAC album. func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) { - return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime) + sheet, err := ParseCueFile(cuePath) + if err != nil { + return nil, err + } + audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "") + if err != nil { + return nil, err + } + return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime) } // ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters @@ -441,23 +449,35 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult // - fileModTime: if > 0, used as the FileModTime for all results instead of // stat-ing the cuePath on disk (useful when the real file lives behind SAF) func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { - return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) -} - -func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { sheet, err := ParseCueFile(cuePath) if err != nil { return nil, err } + audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir) + if err != nil { + return nil, err + } + return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime) +} - // Resolve audio file — optionally in an overridden directory +func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) { + if sheet == nil { + return "", fmt.Errorf("cue sheet is nil for %s", cuePath) + } resolveBase := cuePath if audioDir != "" { resolveBase = filepath.Join(audioDir, filepath.Base(cuePath)) } audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName) if audioPath == "" { - return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName) + return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName) + } + return audioPath, nil +} + +func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + if sheet == nil { + return nil, fmt.Errorf("cue sheet is nil for %s", cuePath) } // Try to get quality info from the audio file diff --git a/go_backend/exports.go b/go_backend/exports.go index 00791180..ec757a2d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -3098,3 +3098,7 @@ func CancelLibraryScanJSON() { func ReadAudioMetadataJSON(filePath string) (string, error) { return ReadAudioMetadata(filePath) } + +func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) { + return ReadAudioMetadataWithDisplayName(filePath, displayName) +} diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 34d5c718..cb831364 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -71,6 +71,11 @@ type libraryAudioFileInfo struct { modTime int64 } +type scannedCueFileInfo struct { + sheet *CueSheet + audioPath string +} + func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) { var files []libraryAudioFileInfo @@ -144,12 +149,7 @@ func ScanLibraryFolder(folderPath string) (string, error) { return "[]", err } - audioFiles := make([]string, 0, len(audioFileInfos)) - for _, fileInfo := range audioFileInfos { - audioFiles = append(audioFiles, fileInfo.path) - } - - totalFiles := len(audioFiles) + totalFiles := len(audioFileInfos) libraryScanProgressMu.Lock() libraryScanProgress.TotalFiles = totalFiles libraryScanProgressMu.Unlock() @@ -169,22 +169,29 @@ func ScanLibraryFolder(folderPath string) (string, error) { // 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 _, filePath := range audioFiles { + 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, filePath := range audioFiles { + for i, fileInfo := range audioFileInfos { + filePath := fileInfo.path select { case <-cancelCh: return "[]", fmt.Errorf("scan cancelled") @@ -201,7 +208,20 @@ func ScanLibraryFolder(folderPath string) (string, error) { // Handle .cue files: produce multiple track results if ext == ".cue" { - cueResults, err := ScanCueFileForLibrary(filePath, scanTime) + 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) @@ -219,7 +239,7 @@ func ScanLibraryFolder(folderPath string) (string, error) { continue } - result, err := scanAudioFile(filePath, scanTime) + result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime) if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err) @@ -245,7 +265,15 @@ func ScanLibraryFolder(folderPath string) (string, error) { } func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { - ext := strings.ToLower(filepath.Ext(filePath)) + 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), @@ -254,7 +282,9 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { Format: strings.TrimPrefix(ext, "."), } - if info, err := os.Stat(filePath); err == nil { + if knownModTime > 0 { + result.FileModTime = knownModTime + } else if info, err := os.Stat(filePath); err == nil { result.FileModTime = info.ModTime().UnixMilli() } @@ -262,7 +292,7 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() if coverCacheDir != "" && ext != ".m4a" { - coverPath, err := SaveCoverToCache(filePath, coverCacheDir) + coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir) if err == nil && coverPath != "" { result.CoverPath = coverPath } @@ -276,15 +306,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { case ".mp3": return scanMP3File(filePath, result) case ".opus", ".ogg": - return scanOggFile(filePath, result) + return scanOggFile(filePath, result, displayNameHint) default: - return scanFromFilename(filePath, result) + return scanFromFilename(filePath, displayNameHint, result) } } -func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) { +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(filePath), filepath.Ext(filePath)) + result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource)) } if result.ArtistName == "" { result.ArtistName = "Unknown Artist" @@ -297,7 +343,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) { func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadMetadata(filePath) if err != nil { - return scanFromFilename(filePath, result) + return scanFromFilename(filePath, "", result) } result.TrackName = metadata.Title @@ -319,7 +365,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul } } - applyDefaultLibraryMetadata(filePath, result) + applyDefaultLibraryMetadata(filePath, "", result) return result, nil } @@ -331,14 +377,14 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult result.SampleRate = quality.SampleRate } - return scanFromFilename(filePath, result) + 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) + return scanFromFilename(filePath, "", result) } result.TrackName = metadata.Title @@ -365,16 +411,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - applyDefaultLibraryMetadata(filePath, result) + applyDefaultLibraryMetadata(filePath, "", result) return result, nil } -func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { +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, result) + return scanFromFilename(filePath, displayNameHint, result) } result.TrackName = metadata.Title @@ -397,13 +443,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - applyDefaultLibraryMetadata(filePath, result) + applyDefaultLibraryMetadata(filePath, displayNameHint, result) return result, nil } -func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { - filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) +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 { @@ -426,7 +473,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR dir := filepath.Dir(filePath) result.AlbumName = filepath.Base(dir) - if result.AlbumName == "." || result.AlbumName == "" { + if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" { result.AlbumName = "Unknown Album" } @@ -473,8 +520,12 @@ func CancelLibraryScan() { } 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 := scanAudioFile(filePath, scanTime) + result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0) if err != nil { return "", err } @@ -541,14 +592,13 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, // Find files to scan (new or modified) var filesToScan []libraryAudioFileInfo skippedCount := 0 - - // Build a set of existing CUE virtual path base files for incremental matching. - // CUE tracks are stored with virtual paths like "/path/album.cue#track01". - // We need to match these against the actual .cue file's modTime. - cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk - for _, f := range currentFiles { - if strings.ToLower(filepath.Ext(f.path)) == ".cue" { - cueBaseModTimes[f.path] = f.modTime + 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 + } } } @@ -557,25 +607,12 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, if !exists { // For .cue files, also check if any virtual path entries exist if strings.ToLower(filepath.Ext(f.path)) == ".cue" { - hasCueTracks := false - for existingPath := range existingFiles { - if strings.HasPrefix(existingPath, f.path+"#track") { - hasCueTracks = true - break - } - } - if hasCueTracks { + if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks { // CUE file exists in DB via virtual paths; check if modTime changed - // Use modTime from any virtual path (they all share the same .cue modTime) - for existingPath, modTime := range existingFiles { - if strings.HasPrefix(existingPath, f.path+"#track") { - if f.modTime == modTime { - skippedCount++ - } else { - filesToScan = append(filesToScan, f) - } - break - } + if f.modTime == cueTrackModTime { + skippedCount++ + } else { + filesToScan = append(filesToScan, f) } continue } @@ -630,6 +667,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, // 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" { @@ -637,6 +675,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, if err == nil && sheet.FileName != "" { audioPath := ResolveCueAudioPath(f.path, sheet.FileName) if audioPath != "" { + parsedCueFiles[f.path] = scannedCueFileInfo{ + sheet: sheet, + audioPath: audioPath, + } cueReferencedAudioFilesInc[audioPath] = true } } @@ -660,7 +702,20 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, // Handle .cue files: produce multiple track results if ext == ".cue" { - cueResults, err := ScanCueFileForLibrary(f.path, scanTime) + 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) @@ -675,7 +730,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, continue } - result, err := scanAudioFile(f.path, scanTime) + result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime) if err != nil { errorCount++ GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err) diff --git a/lib/main.dart b/lib/main.dart index 6963a9ee..e0f155e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -89,14 +90,51 @@ class _EagerInitialization extends ConsumerStatefulWidget { } class _EagerInitializationState extends ConsumerState<_EagerInitialization> { + ProviderSubscription? _localLibraryEnabledSub; + bool _localLibraryPreloaded = false; + @override void initState() { super.initState(); _initializeAppServices(); _initializeExtensions(); ref.read(downloadHistoryProvider); - ref.read(localLibraryProvider); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _initializeDeferredProviders(); + }); + } + + @override + void dispose() { + _localLibraryEnabledSub?.close(); + super.dispose(); + } + + void _initializeDeferredProviders() { ref.read(libraryCollectionsProvider); + _maybePreloadLocalLibrary( + ref.read( + settingsProvider.select((settings) => settings.localLibraryEnabled), + ), + ); + + _localLibraryEnabledSub = ref.listenManual( + settingsProvider.select((settings) => settings.localLibraryEnabled), + (previous, next) { + if (next == true) { + _maybePreloadLocalLibrary(true); + } + }, + ); + } + + void _maybePreloadLocalLibrary(bool enabled) { + if (!enabled || _localLibraryPreloaded) return; + _localLibraryPreloaded = true; + ref.read(localLibraryProvider); + _localLibraryEnabledSub?.close(); + _localLibraryEnabledSub = null; } Future _initializeAppServices() async { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 18348f96..7dc37dd3 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -205,6 +205,7 @@ class DownloadHistoryState { final List items; final Map _bySpotifyId; final Map _byIsrc; + final Map _byTrackArtistKey; DownloadHistoryState({this.items = const []}) : _bySpotifyId = Map.fromEntries( @@ -218,8 +219,25 @@ class DownloadHistoryState { items .where((item) => item.isrc != null && item.isrc!.isNotEmpty) .map((item) => MapEntry(item.isrc!, item)), + ), + _byTrackArtistKey = Map.fromEntries( + items + .map( + (item) => MapEntry( + _trackArtistKey(item.trackName, item.artistName), + item, + ), + ) + .where((entry) => entry.key.isNotEmpty), ); + static String _trackArtistKey(String trackName, String artistName) { + final normalizedTrack = trackName.trim().toLowerCase(); + if (normalizedTrack.isEmpty) return ''; + final normalizedArtist = artistName.trim().toLowerCase(); + return '$normalizedTrack|$normalizedArtist'; + } + bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId); DownloadHistoryItem? getBySpotifyId(String spotifyId) => @@ -231,16 +249,9 @@ class DownloadHistoryState { String trackName, String artistName, ) { - final normalizedTrack = trackName.trim().toLowerCase(); - final normalizedArtist = artistName.trim().toLowerCase(); - if (normalizedTrack.isEmpty) return null; - for (final item in items) { - if (item.trackName.trim().toLowerCase() == normalizedTrack && - item.artistName.trim().toLowerCase() == normalizedArtist) { - return item; - } - } - return null; + final key = _trackArtistKey(trackName, artistName); + if (key.isEmpty) return null; + return _byTrackArtistKey[key]; } DownloadHistoryState copyWith({List? items}) { @@ -252,10 +263,12 @@ class DownloadHistoryNotifier extends Notifier { static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; static const int _audioMetadataBackfillMaxPerLaunch = 24; + static const _startupMaintenanceDelay = Duration(seconds: 2); final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; bool _isSafRepairInProgress = false; bool _isAudioMetadataBackfillInProgress = false; + bool _startupMaintenanceScheduled = false; @override DownloadHistoryState build() { @@ -292,33 +305,45 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: items); _historyLog.i('Loaded ${items.length} items from SQLite database'); - - if (Platform.isAndroid) { - Future.microtask(() async { - await _repairMissingSafEntries( - items, - maxItems: _safRepairMaxPerLaunch, - ); - await cleanupOrphanedDownloads(); - await _backfillAudioMetadata( - state.items, - maxItems: _audioMetadataBackfillMaxPerLaunch, - ); - }); - } else { - Future.microtask(() async { - await cleanupOrphanedDownloads(); - await _backfillAudioMetadata( - state.items, - maxItems: _audioMetadataBackfillMaxPerLaunch, - ); - }); - } + _scheduleStartupMaintenance(items); } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); } } + void _scheduleStartupMaintenance(List initialItems) { + if (_startupMaintenanceScheduled) { + return; + } + _startupMaintenanceScheduled = true; + + unawaited( + Future.delayed(_startupMaintenanceDelay, () async { + try { + if (Platform.isAndroid) { + await _repairMissingSafEntries( + initialItems, + maxItems: _safRepairMaxPerLaunch, + ); + } + + await cleanupOrphanedDownloads(); + + final currentItems = state.items; + if (currentItems.isNotEmpty) { + await _backfillAudioMetadata( + currentItems, + maxItems: _audioMetadataBackfillMaxPerLaunch, + ); + } + } catch (e, stack) { + _historyLog.w('Startup history maintenance failed: $e'); + _historyLog.d('$stack'); + } + }), + ); + } + String _fileNameFromUri(String uri) { try { final parsed = Uri.parse(uri); @@ -1912,7 +1937,12 @@ class DownloadQueueNotifier extends Notifier { ); } - String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) { + String addToQueue( + Track track, + String service, { + String? qualityOverride, + String? playlistName, + }) { final settings = ref.read(settingsProvider); updateSettings(settings);