From 5256d6197b5e6913cb08a1129439b2976218bace Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 8 Feb 2026 12:01:08 +0700 Subject: [PATCH] fix: metadata enrichment bug and upgrade go-flac to v2 - Fix metadata enrichment bug where failed downloads poison connection pool - Create separate metadataTransport for Deezer API calls - Add immediate connection cleanup after download failures - Fix Samsung One UI local library scan with MediaStore fallback - Fix 'In Library' tracks still showing as downloadable - Upgrade go-flac packages to v2 (flacpicture v2.0.2, flacvorbis v2.0.2, go-flac v2.0.4) - Update CHANGELOG.md v3.5.2 --- CHANGELOG.md | 13 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 327 +++++++++++++++--- go_backend/deezer.go | 2 +- go_backend/go.mod | 3 - go_backend/go.sum | 19 +- go_backend/httputil.go | 31 ++ go_backend/metadata.go | 6 +- go_backend/songlink.go | 2 +- go_backend/spotify.go | 2 +- lib/providers/download_queue_provider.dart | 15 + lib/screens/album_screen.dart | 2 +- lib/screens/artist_screen.dart | 2 +- lib/screens/home_tab.dart | 2 +- lib/screens/playlist_screen.dart | 2 +- 14 files changed, 352 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42222421..c7127f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [3.5.2] - 2026-02-08 + +### Fixed + +- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal +- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission +- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely +- Added visited directory tracking to prevent infinite loops from circular SAF references +- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests +- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads +- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`) +- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search) + ## [3.5.1] - 2026-02-08 ### Performance 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 30116834..c00c5d21 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -424,36 +424,159 @@ class MainActivity: FlutterFragmentActivity() { return obj.toString() } - private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { - val mime = contentResolver.getType(uri) - val nameHint = ( - DocumentFile.fromSingleUri(this, uri)?.name - ?: uri.lastPathSegment - ?: "" - ).lowercase(Locale.ROOT) - val extFromName = when { - nameHint.endsWith(".m4a") -> ".m4a" - nameHint.endsWith(".mp3") -> ".mp3" - nameHint.endsWith(".opus") -> ".opus" - nameHint.endsWith(".flac") -> ".flac" + /** + * Detect whether a content URI belongs to the MediaStore provider. + * Samsung One UI may return MediaStore URIs from SAF tree traversal, + * which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission + * instead of SAF tree permission. + */ + private fun isMediaStoreUri(uri: Uri): Boolean { + val authority = uri.authority ?: return false + return authority == "media" || + authority.startsWith("media.") || + authority.contains("media") + } + + /** + * Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE. + */ + private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String { + // Try DISPLAY_NAME first + try { + contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: "" + val ext = extFromFileName(name) + if (ext.isNotBlank()) return ext + } + } + } catch (_: Exception) {} + + // Try MIME_TYPE + try { + val mime = contentResolver.getType(uri) + val ext = extFromMimeType(mime) + if (ext.isNotBlank()) return ext + } catch (_: Exception) {} + + return fallbackExt ?: "" + } + + private fun extFromFileName(name: String): String { + return when { + name.endsWith(".m4a") -> ".m4a" + name.endsWith(".mp3") -> ".mp3" + name.endsWith(".opus") -> ".opus" + name.endsWith(".flac") -> ".flac" + name.endsWith(".ogg") -> ".ogg" else -> "" } - val extFromMime = when (mime) { + } + + private fun extFromMimeType(mime: String?): String { + return when (mime) { "audio/mp4" -> ".m4a" "audio/mpeg" -> ".mp3" "audio/ogg" -> ".opus" "audio/flac" -> ".flac" else -> "" } - val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") - val suffix: String? = if (ext.isNotBlank()) ext else null - val tempFile = File.createTempFile("saf_", suffix, cacheDir) - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output) + } + + private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { + var tempFile: File? = null + var success = false + + try { + val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null } + val nameHint = ( + try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } + ?: uri.lastPathSegment + ?: "" + ).lowercase(Locale.ROOT) + val extFromName = extFromFileName(nameHint) + val extFromMime = extFromMimeType(mime) + val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") + val suffix: String? = if (ext.isNotBlank()) ext else null + tempFile = File.createTempFile("saf_", suffix, cacheDir) + + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } ?: return null + + success = true + return tempFile.absolutePath + } catch (e: SecurityException) { + // SAF permission denied - try MediaStore fallback for Samsung One UI + // which may return MediaStore URIs from SAF tree traversal + if (isMediaStoreUri(uri)) { + android.util.Log.d( + "SpotiFLAC", + "SAF denied for MediaStore URI, trying MediaStore fallback: $uri", + ) + val result = copyMediaStoreUriToTemp(uri, fallbackExt) + if (result != null) { + success = true + return result + } } - } ?: return null - return tempFile.absolutePath + android.util.Log.w( + "SpotiFLAC", + "SAF read denied for $uri: ${e.message}", + ) + return null + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed copying SAF uri $uri to temp: ${e.message}", + ) + return null + } finally { + if (!success) { + try { + tempFile?.delete() + } catch (_: Exception) {} + } + } + } + + /** + * Fallback for Samsung One UI: read a MediaStore content URI using + * READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF. + * This handles the case where SAF tree traversal returns MediaStore URIs + * that the SAF document provider cannot access. + */ + private fun copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? { + var tempFile: File? = null + try { + val ext = resolveMediaStoreExt(uri, fallbackExt) + val suffix: String? = if (ext.isNotBlank()) ext else null + tempFile = File.createTempFile("ms_", suffix, cacheDir) + + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } ?: run { + tempFile.delete() + return null + } + + android.util.Log.d( + "SpotiFLAC", + "MediaStore fallback succeeded for $uri", + ) + return tempFile.absolutePath + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "MediaStore fallback also failed for $uri: ${e.message}", + ) + try { tempFile?.delete() } catch (_: Exception) {} + return null + } } private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { @@ -547,9 +670,14 @@ class MainActivity: FlutterFragmentActivity() { resetSafScanProgress() safScanCancel = false safScanActive = true + updateSafScanProgress { + it.currentFile = "Scanning folders..." + } val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() + val visitedDirUris = mutableSetOf() + var traversalErrors = 0 val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -561,22 +689,52 @@ class MainActivity: FlutterFragmentActivity() { } val (dir, path) = queue.removeFirst() - for (child in dir.listFiles()) { + val dirUri = dir.uri.toString() + if (!visitedDirUris.add(dirUri)) { + continue + } + + val children = try { + dir.listFiles() + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF scan: failed listing directory $dirUri: ${e.message}", + ) + continue + } + + for (child in children) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } return "[]" } - if (child.isDirectory) { - val childName = child.name ?: continue - val childPath = if (path.isBlank()) childName else "$path/$childName" - queue.add(child to childPath) - } else if (child.isFile) { - val name = child.name ?: continue - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { - audioFiles.add(child to path) + try { + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + val childUri = child.uri.toString() + if (childUri == dirUri || visitedDirUris.contains(childUri)) { + continue + } + queue.add(child to childPath) + } else if (child.isFile) { + val name = child.name ?: continue + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + audioFiles.add(child to path) + } } + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF scan: skipped child under $dirUri: ${e.message}", + ) } } } @@ -595,7 +753,7 @@ class MainActivity: FlutterFragmentActivity() { val results = JSONArray() var scanned = 0 - var errors = 0 + var errors = traversalErrors for ((doc, _) in audioFiles) { if (safScanCancel) { @@ -603,14 +761,22 @@ class MainActivity: FlutterFragmentActivity() { return "[]" } - val name = doc.name ?: "" + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name } val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val tempPath = copyUriToTemp(doc.uri, fallbackExt) + 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) { errors++ } else { @@ -618,7 +784,7 @@ class MainActivity: FlutterFragmentActivity() { val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) if (metadataJson.isNotBlank()) { val obj = JSONObject(metadataJson) - val lastModified = doc.lastModified() + val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } obj.put("filePath", doc.uri.toString()) obj.put("fileModTime", lastModified) results.put(obj) @@ -691,10 +857,15 @@ class MainActivity: FlutterFragmentActivity() { resetSafScanProgress() safScanCancel = false safScanActive = true + updateSafScanProgress { + it.currentFile = "Scanning folders..." + } val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() // doc, path, lastModified val currentUris = mutableSetOf() + val visitedDirUris = mutableSetOf() + var traversalErrors = 0 // Collect all audio files with lastModified val queue: ArrayDeque> = ArrayDeque() @@ -713,7 +884,24 @@ class MainActivity: FlutterFragmentActivity() { } val (dir, path) = queue.removeFirst() - for (child in dir.listFiles()) { + val dirUri = dir.uri.toString() + if (!visitedDirUris.add(dirUri)) { + continue + } + + val children = try { + dir.listFiles() + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF incremental scan: failed listing directory $dirUri: ${e.message}", + ) + continue + } + + for (child in children) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } val result = JSONObject() @@ -725,24 +913,44 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - if (child.isDirectory) { - val childName = child.name ?: continue - val childPath = if (path.isBlank()) childName else "$path/$childName" - queue.add(child to childPath) - } else if (child.isFile) { - val name = child.name ?: continue - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + try { + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + val childUri = child.uri.toString() + if (childUri == dirUri || visitedDirUris.contains(childUri)) { + continue + } + queue.add(child to childPath) + } else if (child.isFile) { + // Mark file as present first so it cannot be mis-classified as removed + // when provider-specific metadata calls (e.g., lastModified) fail. val uriStr = child.uri.toString() - val lastModified = child.lastModified() currentUris.add(uriStr) - - // Check if file is new or modified - val existingModified = existingFiles[uriStr] - if (existingModified == null || existingModified != lastModified) { - audioFiles.add(Triple(child, path, lastModified)) + + val name = child.name ?: continue + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + val existingModified = existingFiles[uriStr] + val lastModified = try { + child.lastModified() + } catch (_: Exception) { + existingModified ?: 0L + } + + // Check if file is new or modified + if (existingModified == null || existingModified != lastModified) { + audioFiles.add(Triple(child, path, lastModified)) + } } } + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF incremental scan: skipped child under $dirUri: ${e.message}", + ) } } } @@ -772,7 +980,7 @@ class MainActivity: FlutterFragmentActivity() { val results = JSONArray() var scanned = 0 - var errors = 0 + var errors = traversalErrors for ((doc, _, lastModified) in audioFiles) { if (safScanCancel) { @@ -786,14 +994,22 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - val name = doc.name ?: "" + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name } val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val tempPath = copyUriToTemp(doc.uri, fallbackExt) + 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) { errors++ } else { @@ -801,9 +1017,10 @@ class MainActivity: FlutterFragmentActivity() { 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", lastModified) - obj.put("lastModified", lastModified) + obj.put("fileModTime", safeLastModified) + obj.put("lastModified", safeLastModified) results.put(obj) } else { errors++ diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 9f1c4430..dfddb890 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -47,7 +47,7 @@ var ( func GetDeezerClient() *DeezerClient { deezerClientOnce.Do(func() { deezerClient = &DeezerClient{ - httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile), + httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile), searchCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry), diff --git a/go_backend/go.mod b/go_backend/go.mod index f2727b34..a7f0daef 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -7,10 +7,7 @@ toolchain go1.25.7 require ( github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/go-flac/flacpicture/v2 v2.0.2 - github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2 - github.com/go-flac/flacvorbis/v2 v2.0.2 - github.com/go-flac/go-flac/v2 v2.0.4 github.com/go-flac/go-flac/v2 v2.0.4 github.com/refraction-networking/utls v1.8.2 golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 diff --git a/go_backend/go.sum b/go_backend/go.sum index f2f4d947..3840d5bf 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -2,18 +2,17 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0 github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= -github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= -github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= +github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= -github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= -github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= +github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= -github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= -github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= +github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= @@ -23,12 +22,14 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU= -golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= @@ -45,3 +46,5 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 4ac2d30c..d6033243 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -55,6 +55,27 @@ var sharedTransport = &http.Transport{ DisableCompression: true, } +// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink). +// Isolated from download traffic so that download failures cannot poison +// the connection pool used by metadata enrichment. +var metadataTransport = &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 30, + MaxIdleConnsPerHost: 5, + MaxConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + DisableKeepAlives: false, + ForceAttemptHTTP2: true, + WriteBufferSize: 32 * 1024, + ReadBufferSize: 32 * 1024, + DisableCompression: true, +} + var sharedClient = &http.Client{ Transport: sharedTransport, Timeout: DefaultTimeout, @@ -72,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { } } +// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport. +// Use this for API calls that should not be affected by download traffic. +func NewMetadataHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: metadataTransport, + Timeout: timeout, + } +} + func GetSharedClient() *http.Client { return sharedClient } @@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client { func CloseIdleConnections() { sharedTransport.CloseIdleConnections() + metadataTransport.CloseIdleConnections() } // Also checks for ISP blocking on errors diff --git a/go_backend/metadata.go b/go_backend/metadata.go index d677994c..6db9d7ab 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -9,9 +9,9 @@ import ( "strconv" "strings" - "github.com/go-flac/flacpicture" - "github.com/go-flac/flacvorbis" - "github.com/go-flac/go-flac" + "github.com/go-flac/flacpicture/v2" + "github.com/go-flac/flacvorbis/v2" + "github.com/go-flac/go-flac/v2" ) type Metadata struct { diff --git a/go_backend/songlink.go b/go_backend/songlink.go index aec8e88f..ff7f1bd2 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -37,7 +37,7 @@ var ( func NewSongLinkClient() *SongLinkClient { songLinkClientOnce.Do(func() { globalSongLinkClient = &SongLinkClient{ - client: NewHTTPClientWithTimeout(SongLinkTimeout), + client: NewMetadataHTTPClient(SongLinkTimeout), } }) return globalSongLinkClient diff --git a/go_backend/spotify.go b/go_backend/spotify.go index e16f3305..6501f952 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { src := rand.NewSource(time.Now().UnixNano()) c := &SpotifyMetadataClient{ - httpClient: NewHTTPClientWithTimeout(15 * time.Second), + httpClient: NewMetadataHTTPClient(15 * time.Second), clientID: clientID, clientSecret: clientSecret, rng: rand.New(src), diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ebf76698..9b3f128d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3444,6 +3444,14 @@ class DownloadQueueNotifier extends Notifier { errorType: errorType, ); _failedInSession++; + + // Immediately cleanup connections after failure to prevent + // poisoned connection pool from affecting subsequent downloads + try { + await PlatformBridge.cleanupConnections(); + } catch (e) { + _log.e('Post-failure connection cleanup failed: $e'); + } } _downloadCount++; @@ -3485,6 +3493,13 @@ class DownloadQueueNotifier extends Notifier { errorType: errorType, ); _failedInSession++; + + // Immediately cleanup connections after exception + try { + await PlatformBridge.cleanupConnections(); + } catch (cleanupErr) { + _log.e('Post-exception connection cleanup failed: $cleanupErr'); + } } } } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index b6891479..ebfa0272 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -631,7 +631,7 @@ class _AlbumTrackItem extends ConsumerWidget { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - final showAsDownloaded = isCompleted || (!isQueued && isInHistory); + final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 148957df..9dad8b68 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -955,7 +955,7 @@ if (hasValidImage) final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - final showAsDownloaded = isCompleted || (!isQueued && isInHistory); + final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return InkWell( onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index b62fc783..74bc5571 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2466,7 +2466,7 @@ class _TrackItemWithStatus extends ConsumerWidget { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - final showAsDownloaded = isCompleted || (!isQueued && isInHistory); + final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 5cb1b105..188e2804 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -457,7 +457,7 @@ class _PlaylistTrackItem extends ConsumerWidget { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - final showAsDownloaded = isCompleted || (!isQueued && isInHistory); + final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8),