mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-09 08:03:57 +02:00
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
This commit is contained in:
@@ -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<String, Map<String, DocumentFile>>,
|
||||
): Map<String, DocumentFile> {
|
||||
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<String, Map<String, DocumentFile>>,
|
||||
): 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<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||
var traversalErrors = 0
|
||||
|
||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = 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<Pair<DocumentFile, DocumentFile>>()
|
||||
val currentUris = mutableSetOf<String>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+112
-57
@@ -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)
|
||||
|
||||
+39
-1
@@ -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<bool>? _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<bool>(
|
||||
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<void> _initializeAppServices() async {
|
||||
|
||||
@@ -205,6 +205,7 @@ class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
||||
final Map<String, DownloadHistoryItem> _byIsrc;
|
||||
final Map<String, DownloadHistoryItem> _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<DownloadHistoryItem>? items}) {
|
||||
@@ -252,10 +263,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
|
||||
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<DownloadHistoryItem> initialItems) {
|
||||
if (_startupMaintenanceScheduled) {
|
||||
return;
|
||||
}
|
||||
_startupMaintenanceScheduled = true;
|
||||
|
||||
unawaited(
|
||||
Future<void>.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<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user