diff --git a/CHANGELOG.md b/CHANGELOG.md index aefee703..11d7b97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ - Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled - Post-processing hooks run for SAF content URIs (via temp file bridge) - File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`) +- Android build tooling upgraded to Gradle 9.3.1 (wrapper) +- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug) +- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`) +- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility +- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed ### Fixed @@ -33,6 +38,9 @@ - SAF download fallback: retry in app-private storage when SAF write fails - Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path) - External LRC output in SAF mode +- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices) +- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support +- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling --- diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c1d50a00..caeee0e7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -104,4 +104,5 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.documentfile:documentfile:1.0.1") + implementation("androidx.activity:activity-ktx:1.9.0") } 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 40378c1d..bbefc390 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -6,7 +6,11 @@ import android.net.Uri import android.os.Build import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode +import io.flutter.embedding.android.FlutterFragment +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.android.RenderMode +import io.flutter.embedding.android.TransparencyMode import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterShellArgs import io.flutter.plugin.common.MethodChannel @@ -23,7 +27,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.Locale -class MainActivity: FlutterActivity() { +class MainActivity: FlutterFragmentActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null @@ -105,102 +109,148 @@ class MainActivity: FlutterActivity() { "sm-t225", // Samsung Tab A7 Lite LTE "hammerhead", // Nexus 5 (Adreno 330) ) - } + /** + * Check if device should use Skia instead of Impeller. + * Returns true for devices with old/problematic GPUs or old Android versions. + */ + private fun shouldDisableImpeller(): Boolean { + val hardware = Build.HARDWARE.lowercase(Locale.ROOT) + val board = Build.BOARD.lowercase(Locale.ROOT) + val model = Build.MODEL.lowercase(Locale.ROOT) + val device = Build.DEVICE.lowercase(Locale.ROOT) - /** - * Override Flutter shell args to disable Impeller on problematic devices. - * This is called before the Flutter engine starts. - */ - override fun getFlutterShellArgs(): FlutterShellArgs { - val args = super.getFlutterShellArgs() - - if (shouldDisableImpeller()) { - // Log for debugging - android.util.Log.i("SpotiFLAC", "Legacy/problematic GPU detected: Disabling Impeller for ${Build.MODEL}") - android.util.Log.i("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}") - android.util.Log.i("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}") - - // Disable Impeller, forcing Skia renderer - args.add("--enable-impeller=false") - } else { - android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}") - } - - return args - } - - /** - * Check if device should use Skia instead of Impeller. - * Returns true for devices with old/problematic GPUs or old Android versions. - */ - private fun shouldDisableImpeller(): Boolean { - val hardware = Build.HARDWARE.lowercase(Locale.ROOT) - val board = Build.BOARD.lowercase(Locale.ROOT) - val model = Build.MODEL.lowercase(Locale.ROOT) - val device = Build.DEVICE.lowercase(Locale.ROOT) - - // 1. Check for explicitly problematic device models - for (problematicModel in PROBLEMATIC_MODELS) { - if (model.contains(problematicModel) || device.contains(problematicModel)) { - android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel") - return true - } - } - - // 2. Check for problematic chipsets - for (chipset in PROBLEMATIC_CHIPSETS) { - if (hardware.contains(chipset) || board.contains(chipset)) { - android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset") - return true - } - } - - // 3. For Android < 10 (API 29), be more aggressive about disabling Impeller - if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) { - // For older Android, check GPU renderer if available - val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) - - // Check for known problematic GPUs - for (pattern in PROBLEMATIC_GPU_PATTERNS) { - if (gpuRenderer.contains(pattern)) { - android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern") + // 1. Check for explicitly problematic device models + for (problematicModel in PROBLEMATIC_MODELS) { + if (model.contains(problematicModel) || device.contains(problematicModel)) { + android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel") return true } } - - // For very old Android (< 8.0), always use Skia as Vulkan support is spotty - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety") - return true + + // 2. Check for problematic chipsets + for (chipset in PROBLEMATIC_CHIPSETS) { + if (hardware.contains(chipset) || board.contains(chipset)) { + android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset") + return true + } } - } - - // 4. For Android 10+, still check for known problematic GPUs - val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) - for (pattern in PROBLEMATIC_GPU_PATTERNS) { - if (gpuRenderer.contains(pattern)) { - android.util.Log.i("SpotiFLAC", "Matched problematic GPU: $pattern") - return true + + // 3. For Android < 10 (API 29), be more aggressive about disabling Impeller + if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) { + // For older Android, check GPU renderer if available + val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) + + // Check for known problematic GPUs + for (pattern in PROBLEMATIC_GPU_PATTERNS) { + if (gpuRenderer.contains(pattern)) { + android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern") + return true + } + } + + // For very old Android (< 8.0), always use Skia as Vulkan support is spotty + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety") + return true + } } + + // 4. For Android 10+, still check for known problematic GPUs + val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) + for (pattern in PROBLEMATIC_GPU_PATTERNS) { + if (gpuRenderer.contains(pattern)) { + android.util.Log.i("SpotiFLAC", "Matched problematic GPU: $pattern") + return true + } + } + + return false } - - return false - } - + /** * Try to get GPU renderer string. * Note: This may return empty on some devices before OpenGL context is created. */ - private fun getGpuRenderer(): String { - return try { - // This might not work before GL context is created, - // but worth trying for additional detection - android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: "" - } catch (e: Exception) { - "" + private fun getGpuRenderer(): String { + return try { + // This might not work before GL context is created, + // but worth trying for additional detection + android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: "" + } catch (e: Exception) { + "" + } } } + class ImpellerAwareFlutterFragment : FlutterFragment() { + override fun getFlutterShellArgs(): FlutterShellArgs { + val args = super.getFlutterShellArgs() + if (shouldDisableImpeller()) { + android.util.Log.w("SpotiFLAC", "Legacy/problematic GPU detected for ${Build.MODEL}") + android.util.Log.w("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}") + android.util.Log.w("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}") + args.add("--enable-impeller=false") + } else { + android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}") + } + return args + } + } + + override fun createFlutterFragment(): FlutterFragment { + val backgroundMode = getBackgroundMode() + val renderMode = getRenderMode() + val transparencyMode = + if (backgroundMode == BackgroundMode.opaque) TransparencyMode.opaque else TransparencyMode.transparent + val shouldDelayFirstAndroidViewDraw = renderMode == RenderMode.surface + + getCachedEngineId()?.let { cachedEngineId -> + return FlutterFragment.CachedEngineFragmentBuilder( + ImpellerAwareFlutterFragment::class.java, + cachedEngineId + ) + .renderMode(renderMode) + .transparencyMode(transparencyMode) + .handleDeeplinking(shouldHandleDeeplinking()) + .shouldAttachEngineToActivity(shouldAttachEngineToActivity()) + .destroyEngineWithFragment(shouldDestroyEngineWithHost()) + .shouldDelayFirstAndroidViewDraw(shouldDelayFirstAndroidViewDraw) + .shouldAutomaticallyHandleOnBackPressed(true) + .build() + } + + getCachedEngineGroupId()?.let { cachedEngineGroupId -> + return FlutterFragment.NewEngineInGroupFragmentBuilder( + ImpellerAwareFlutterFragment::class.java, + cachedEngineGroupId + ) + .dartEntrypoint(getDartEntrypointFunctionName()) + .initialRoute(getInitialRoute()) + .handleDeeplinking(shouldHandleDeeplinking()) + .renderMode(renderMode) + .transparencyMode(transparencyMode) + .shouldAttachEngineToActivity(shouldAttachEngineToActivity()) + .shouldDelayFirstAndroidViewDraw(shouldDelayFirstAndroidViewDraw) + .shouldAutomaticallyHandleOnBackPressed(true) + .build() + } + + return FlutterFragment.NewEngineFragmentBuilder(ImpellerAwareFlutterFragment::class.java) + .dartEntrypoint(getDartEntrypointFunctionName()) + .dartLibraryUri(getDartEntrypointLibraryUri() ?: "") + .dartEntrypointArgs(getDartEntrypointArgs() ?: emptyList()) + .initialRoute(getInitialRoute()) + .appBundlePath(getAppBundlePath()) + .flutterShellArgs(FlutterShellArgs.fromIntent(intent)) + .handleDeeplinking(shouldHandleDeeplinking()) + .renderMode(renderMode) + .transparencyMode(transparencyMode) + .shouldAttachEngineToActivity(shouldAttachEngineToActivity()) + .shouldDelayFirstAndroidViewDraw(shouldDelayFirstAndroidViewDraw) + .shouldAutomaticallyHandleOnBackPressed(true) + .build() + } + private fun normalizeExt(ext: String?): String { if (ext.isNullOrBlank()) return "" return if (ext.startsWith(".")) ext.lowercase(Locale.ROOT) else ".${ext.lowercase(Locale.ROOT)}" @@ -347,13 +397,26 @@ class MainActivity: FlutterActivity() { private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { val mime = contentResolver.getType(uri) - val ext = when (mime) { + 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" + else -> "" + } + val extFromMime = when (mime) { "audio/mp4" -> ".m4a" "audio/mpeg" -> ".mp3" "audio/ogg" -> ".opus" "audio/flac" -> ".flac" - else -> fallbackExt ?: "" + 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 -> @@ -407,9 +470,12 @@ class MainActivity: FlutterActivity() { val pfd = contentResolver.openFileDescriptor(document.uri, "rw") ?: return errorJson("Failed to open SAF file") + var detachedFd: Int? = null try { - req.put("output_path", "/proc/self/fd/${pfd.fd}") + detachedFd = pfd.detachFd() + req.put("output_path", "/proc/self/fd/$detachedFd") + req.put("output_fd", detachedFd) req.put("output_ext", outputExt) val response = downloader(req.toString()) val respObj = JSONObject(response) @@ -424,9 +490,11 @@ class MainActivity: FlutterActivity() { document.delete() return errorJson("SAF download failed: ${e.message}") } finally { - try { - pfd.close() - } catch (_: Exception) {} + if (detachedFd == null) { + try { + pfd.close() + } catch (_: Exception) {} + } } } @@ -677,7 +745,7 @@ class MainActivity: FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> scope.launch { try { @@ -917,9 +985,12 @@ class MainActivity: FlutterActivity() { if (treeUriStr.isBlank()) return@withContext null val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null val existing = dir.findFile(fileName) + val createdNew = existing == null val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null if (!writeUriFromPath(doc.uri, srcPath)) { - doc.delete() + if (createdNew) { + doc.delete() + } return@withContext null } doc.uri.toString() @@ -957,7 +1028,22 @@ class MainActivity: FlutterActivity() { val filePath = call.argument("file_path") ?: "" val durationMs = call.argument("duration_ms")?.toLong() ?: 0L val response = withContext(Dispatchers.IO) { - Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs) + if (filePath.startsWith("content://")) { + val tempPath = copyUriToTemp(Uri.parse(filePath)) + if (tempPath == null) { + "" + } else { + try { + Gobackend.getLyricsLRC(spotifyId, trackName, artistName, tempPath, durationMs) + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + } else { + Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs) + } } result.success(response) } @@ -965,7 +1051,33 @@ class MainActivity: FlutterActivity() { val filePath = call.argument("file_path") ?: "" val lyrics = call.argument("lyrics") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.embedLyricsToFile(filePath, lyrics) + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri, ".flac") + ?: return@withContext errorJson("Failed to copy SAF file to temp") + try { + val raw = Gobackend.embedLyricsToFile(tempPath, lyrics) + val obj = JSONObject(raw) + if (!obj.optBoolean("success", false)) { + return@withContext raw + } + + if (!writeUriFromPath(uri, tempPath)) { + return@withContext errorJson("Failed to write embedded lyrics back to SAF file") + } + + obj.put("file_path", filePath) + obj.toString() + } catch (e: Exception) { + errorJson("Failed to embed lyrics to SAF file: ${e.message}") + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } else { + Gobackend.embedLyricsToFile(filePath, lyrics) + } } result.success(response) } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43fb..a20f2c46 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 6afa0393..33bd6203 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -149,7 +149,7 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin return downloadURL, fileName, nil } -func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { +func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { ctx := context.Background() if itemID != "" { @@ -188,7 +188,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + out, err := openOutputForWrite(outputPath, outputFD) if err != nil { return err } @@ -207,23 +207,23 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) closeErr := out.Close() if err != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to flush buffer: %w", flushErr) } if closeErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to close file: %w", closeErr) } if expectedSize > 0 && written != expectedSize { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) } @@ -249,7 +249,7 @@ type AmazonDownloadResult struct { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() - isSafOutput := strings.TrimSpace(req.OutputPath) != "" + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" if !isSafOutput { if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil @@ -317,6 +317,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { var outputPath string if isSafOutput { outputPath = strings.TrimSpace(req.OutputPath) + if outputPath == "" && isFDOutput(req.OutputFD) { + outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) + } } else { filename = sanitizeFilename(filename) + ".flac" outputPath = filepath.Join(req.OutputDir, filename) @@ -342,7 +345,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { }() // Download audio file with item ID for progress tracking - if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { + if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return AmazonDownloadResult{}, ErrDownloadCancelled } @@ -415,54 +418,63 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { - GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) - } - - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" + if isSafOutput { + GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } else { + if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { + GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) } - if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { - GoLog("[Amazon] Saving external LRC file...\n") - if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Amazon] LRC file saved: %s\n", lrcPath) + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" } - } - if lyricsMode == "embed" || lyricsMode == "both" { - GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - GoLog("[Amazon] Lyrics embedded successfully\n") + if lyricsMode == "external" || lyricsMode == "both" { + GoLog("[Amazon] Saving external LRC file...\n") + if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Amazon] LRC file saved: %s\n", lrcPath) + } } + + if lyricsMode == "embed" || lyricsMode == "both" { + GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + GoLog("[Amazon] Lyrics embedded successfully\n") + } + } + } else if req.EmbedLyrics { + GoLog("[Amazon] No lyrics available from parallel fetch\n") } - } else if req.EmbedLyrics { - GoLog("[Amazon] No lyrics available from parallel fetch\n") } GoLog("[Amazon] Downloaded successfully from Amazon Music\n") - quality, err := GetAudioQuality(outputPath) - if err != nil { - GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) + quality := AudioQuality{} + if isSafOutput { + GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n") } else { - GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) - } + quality, err = GetAudioQuality(outputPath) + if err != nil { + GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) + } else { + GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + } - finalMeta, metaReadErr := ReadMetadata(outputPath) - if metaReadErr == nil && finalMeta != nil { - GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", - finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) - actualTrackNum = finalMeta.TrackNumber - actualDiscNum = finalMeta.DiscNumber - if finalMeta.Date != "" { - req.ReleaseDate = finalMeta.Date + finalMeta, metaReadErr := ReadMetadata(outputPath) + if metaReadErr == nil && finalMeta != nil { + GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", + finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) + actualTrackNum = finalMeta.TrackNumber + actualDiscNum = finalMeta.DiscNumber + if finalMeta.Date != "" { + req.ReleaseDate = finalMeta.Date + } } } diff --git a/go_backend/exports.go b/go_backend/exports.go index b7ce52d6..7c5eba22 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -129,6 +129,7 @@ type DownloadRequest struct { CoverURL string `json:"cover_url"` OutputDir string `json:"output_dir"` OutputPath string `json:"output_path,omitempty"` + OutputFD int `json:"output_fd,omitempty"` OutputExt string `json:"output_ext,omitempty"` FilenameFormat string `json:"filename_format"` Quality string `json:"quality"` @@ -204,7 +205,7 @@ func DownloadTrack(requestJSON string) (string, error) { req.OutputPath = strings.TrimSpace(req.OutputPath) req.OutputExt = strings.TrimSpace(req.OutputExt) - if req.OutputPath == "" && req.OutputDir != "" { + if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -345,7 +346,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { req.OutputPath = strings.TrimSpace(req.OutputPath) req.OutputExt = strings.TrimSpace(req.OutputExt) - if req.OutputPath == "" && req.OutputDir != "" { + if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -1285,7 +1286,7 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputPath = strings.TrimSpace(req.OutputPath) req.OutputExt = strings.TrimSpace(req.OutputExt) - if req.OutputPath == "" && req.OutputDir != "" { + if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go new file mode 100644 index 00000000..53a2bd3f --- /dev/null +++ b/go_backend/output_fd.go @@ -0,0 +1,31 @@ +package gobackend + +import ( + "fmt" + "os" + "strings" +) + +func isFDOutput(outputFD int) bool { + return outputFD > 0 +} + +func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { + if isFDOutput(outputFD) { + return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil + } + return os.Create(outputPath) +} + +func cleanupOutputOnError(outputPath string, outputFD int) { + if isFDOutput(outputFD) { + return + } + + path := strings.TrimSpace(outputPath) + if path == "" || strings.HasPrefix(path, "/proc/self/fd/") { + return + } + + _ = os.Remove(path) +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 156ca492..d8bece89 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -380,6 +380,42 @@ func decodeXOR(data []byte) string { return string(result) } +func extractQobuzDownloadURLFromBody(body []byte) (string, error) { + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return "", fmt.Errorf("invalid JSON: %v", err) + } + + if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { + return "", fmt.Errorf("%s", errMsg) + } + + if success, ok := raw["success"].(bool); ok && !success { + if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" { + return "", fmt.Errorf("%s", msg) + } + return "", fmt.Errorf("api returned success=false") + } + + if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" { + return strings.TrimSpace(linkVal), nil + } + + if data, ok := raw["data"].(map[string]any); ok { + if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" { + return strings.TrimSpace(linkVal), nil + } + } + + return "", fmt.Errorf("no download URL in response") +} + func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { formatID := mapJumoQuality(quality) region := "US" @@ -810,27 +846,12 @@ func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout t return "", fmt.Errorf("received HTML instead of JSON") } - var errorResp struct { - Error string `json:"error"` + urlVal, parseErr := extractQobuzDownloadURLFromBody(body) + if parseErr == nil { + return urlVal, nil } - if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { - // API-level errors are usually not retryable (track not found, etc.) - return "", fmt.Errorf("%s", errorResp.Error) - } - - var result struct { - URL string `json:"url"` - } - if err := json.Unmarshal(body, &result); err != nil { - lastErr = fmt.Errorf("invalid JSON: %v", err) - continue - } - - if result.URL != "" { - return result.URL, nil - } - - return "", fmt.Errorf("no download URL in response") + lastErr = parseErr + continue } if lastErr != nil { @@ -926,7 +947,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) } -func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { +func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { ctx := context.Background() if itemID != "" { @@ -963,7 +984,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + out, err := openOutputForWrite(outputPath, outputFD) if err != nil { return err } @@ -982,23 +1003,23 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e closeErr := out.Close() if err != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to flush buffer: %w", flushErr) } if closeErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to close file: %w", closeErr) } if expectedSize > 0 && written != expectedSize { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) } @@ -1022,7 +1043,7 @@ type QobuzDownloadResult struct { func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { downloader := NewQobuzDownloader() - isSafOutput := strings.TrimSpace(req.OutputPath) != "" + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" if !isSafOutput { if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil @@ -1137,6 +1158,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { var outputPath string if isSafOutput { outputPath = strings.TrimSpace(req.OutputPath) + if outputPath == "" && isFDOutput(req.OutputFD) { + outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) + } } else { filename = sanitizeFilename(filename) + ".flac" outputPath = filepath.Join(req.OutputDir, filename) @@ -1180,7 +1204,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { ) }() - if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { + if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return QobuzDownloadResult{}, ErrDownloadCancelled } @@ -1225,35 +1249,39 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } - if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { - fmt.Printf("Warning: failed to embed metadata: %v\n", err) - } - - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" + if isSafOutput { + GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } else { + if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { + fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { - GoLog("[Qobuz] Saving external LRC file...\n") - if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Qobuz] LRC file saved: %s\n", lrcPath) + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" } - } - if lyricsMode == "embed" || lyricsMode == "both" { - GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Qobuz] Lyrics embedded successfully") + if lyricsMode == "external" || lyricsMode == "both" { + GoLog("[Qobuz] Saving external LRC file...\n") + if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Qobuz] LRC file saved: %s\n", lrcPath) + } } + + if lyricsMode == "embed" || lyricsMode == "both" { + GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) + } else { + fmt.Println("[Qobuz] Lyrics embedded successfully") + } + } + } else if req.EmbedLyrics { + fmt.Println("[Qobuz] No lyrics available from parallel fetch") } - } else if req.EmbedLyrics { - fmt.Println("[Qobuz] No lyrics available from parallel fetch") } if !isSafOutput { diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go new file mode 100644 index 00000000..124cd61e --- /dev/null +++ b/go_backend/qobuz_test.go @@ -0,0 +1,47 @@ +package gobackend + +import "testing" + +func TestExtractQobuzDownloadURLFromBody(t *testing.T) { + t.Run("reads nested data.url", func(t *testing.T) { + body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`) + + got, err := extractQobuzDownloadURLFromBody(body) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != "https://example.test/audio.flac" { + t.Fatalf("unexpected URL: %q", got) + } + }) + + t.Run("reads top-level url", func(t *testing.T) { + body := []byte(`{"url":"https://example.test/top.flac"}`) + + got, err := extractQobuzDownloadURLFromBody(body) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != "https://example.test/top.flac" { + t.Fatalf("unexpected URL: %q", got) + } + }) + + t.Run("returns API error", func(t *testing.T) { + body := []byte(`{"error":"track not found"}`) + + _, err := extractQobuzDownloadURLFromBody(body) + if err == nil || err.Error() != "track not found" { + t.Fatalf("expected track-not-found error, got %v", err) + } + }) + + t.Run("returns message when success false", func(t *testing.T) { + body := []byte(`{"success":false,"message":"blocked"}`) + + _, err := extractQobuzDownloadURLFromBody(body) + if err == nil || err.Error() != "blocked" { + t.Fatalf("expected blocked error, got %v", err) + } + }) +} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 36e3d3e0..cb6c9f5f 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -851,7 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return "", initURL, mediaURLs, nil } -func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { +func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { ctx := context.Background() if strings.HasPrefix(downloadURL, "MANIFEST:") { @@ -864,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e if isDownloadCancelled(itemID) { return ErrDownloadCancelled } - return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) + return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID) } if itemID != "" { @@ -901,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + out, err := openOutputForWrite(outputPath, outputFD) if err != nil { return err } @@ -920,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e closeErr := out.Close() if err != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to flush buffer: %w", flushErr) } if closeErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to close file: %w", closeErr) } if expectedSize > 0 && written != expectedSize { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) } return nil } -func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error { +func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error { fmt.Println("[Tidal] Parsing manifest...") directURL, initURL, mediaURLs, err := parseManifest(manifestB64) if err != nil { @@ -988,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + out, err := openOutputForWrite(outputPath, outputFD) if err != nil { return fmt.Errorf("failed to create file: %w", err) } @@ -1004,19 +1004,19 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, closeErr := out.Close() if err != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } return fmt.Errorf("download interrupted: %w", err) } if closeErr != nil { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to close file: %w", closeErr) } if expectedSize > 0 && written != expectedSize { - os.Remove(outputPath) + cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) } @@ -1037,7 +1037,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, } GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) - out, err := os.Create(m4aPath) + out, err := openOutputForWrite(m4aPath, outputFD) if err != nil { GoLog("[Tidal] Failed to create M4A file: %v\n", err) return fmt.Errorf("failed to create M4A file: %w", err) @@ -1046,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, GoLog("[Tidal] Downloading init segment...\n") if isDownloadCancelled(itemID) { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) return ErrDownloadCancelled } req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil) if err != nil { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) GoLog("[Tidal] Init segment request failed: %v\n", err) return fmt.Errorf("failed to create init segment request: %w", err) } resp, err := client.Do(req) if err != nil { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1069,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, if resp.StatusCode != 200 { resp.Body.Close() out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode) return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) } @@ -1077,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, resp.Body.Close() if err != nil { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1089,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, for i, mediaURL := range mediaURLs { if isDownloadCancelled(itemID) { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) return ErrDownloadCancelled } @@ -1105,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil) if err != nil { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err) return fmt.Errorf("failed to create segment %d request: %w", i+1, err) } resp, err := client.Do(req) if err != nil { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1122,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, if resp.StatusCode != 200 { resp.Body.Close() out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode) return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) } @@ -1130,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, resp.Body.Close() if err != nil { out.Close() - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1140,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, } if err := out.Close(); err != nil { - os.Remove(m4aPath) + cleanupOutputOnError(m4aPath, outputFD) GoLog("[Tidal] Failed to close M4A file: %v\n", err) return fmt.Errorf("failed to close M4A file: %w", err) } @@ -1409,7 +1409,7 @@ func isLatinScript(s string) bool { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() - isSafOutput := strings.TrimSpace(req.OutputPath) != "" + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" if !isSafOutput { if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil @@ -1627,6 +1627,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { var m4aPath string if isSafOutput { outputPath = strings.TrimSpace(req.OutputPath) + if outputPath == "" && isFDOutput(req.OutputFD) { + outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) + } m4aPath = outputPath } else { if outputExt == ".m4a" || quality == "HIGH" { @@ -1689,7 +1692,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return "Direct URL" }()) - if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { + if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return TidalDownloadResult{}, ErrDownloadCancelled } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 73cd110f..b59fc812 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -201,13 +201,15 @@ class DownloadHistoryState { .toSet(), _bySpotifyId = Map.fromEntries( items - .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) - .map((item) => MapEntry(item.spotifyId!, item)), + .where( + (item) => item.spotifyId != null && item.spotifyId!.isNotEmpty, + ) + .map((item) => MapEntry(item.spotifyId!, item)), ), _byIsrc = Map.fromEntries( items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => MapEntry(item.isrc!, item)), + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => MapEntry(item.isrc!, item)), ); bool isDownloaded(String spotifyId) => @@ -216,8 +218,7 @@ class DownloadHistoryState { DownloadHistoryItem? getBySpotifyId(String spotifyId) => _bySpotifyId[spotifyId]; - DownloadHistoryItem? getByIsrc(String isrc) => - _byIsrc[isrc]; + DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); @@ -248,19 +249,19 @@ class DownloadHistoryNotifier extends Notifier { if (migrated) { _historyLog.i('Migrated history from SharedPreferences to SQLite'); } - + if (Platform.isIOS) { final pathsMigrated = await _db.migrateIosContainerPaths(); if (pathsMigrated) { _historyLog.i('Migrated iOS container paths after app update'); } } - + final jsonList = await _db.getAll(); final items = jsonList .map((e) => DownloadHistoryItem.fromJson(e)) .toList(); - + state = state.copyWith(items: items); _historyLog.i('Loaded ${items.length} items from SQLite database'); @@ -355,7 +356,9 @@ class DownloadHistoryNotifier extends Notifier { } if (existing != null) { - final updatedItems = state.items.where((i) => i.id != existing!.id).toList(); + final updatedItems = state.items + .where((i) => i.id != existing!.id) + .toList(); updatedItems.insert(0, item); state = state.copyWith(items: updatedItems); _historyLog.d('Updated existing history entry: ${item.trackName}'); @@ -363,7 +366,7 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: [item, ...state.items]); _historyLog.d('Added new history entry: ${item.trackName}'); } - + _db.upsert(item.toJson()).catchError((e) { _historyLog.e('Failed to save to database: $e'); }); @@ -391,15 +394,15 @@ class DownloadHistoryNotifier extends Notifier { DownloadHistoryItem? getBySpotifyId(String spotifyId) { return state.getBySpotifyId(spotifyId); } - + DownloadHistoryItem? getByIsrc(String isrc) { return state.getByIsrc(isrc); } - + Future getBySpotifyIdAsync(String spotifyId) async { final inMemory = state.getBySpotifyId(spotifyId); if (inMemory != null) return inMemory; - + final json = await _db.getBySpotifyId(spotifyId); if (json == null) return null; return DownloadHistoryItem.fromJson(json); @@ -411,7 +414,7 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.e('Failed to clear database: $e'); }); } - + Future getDatabaseCount() async { return await _db.getCount(); } @@ -758,8 +761,8 @@ class DownloadQueueNotifier extends Notifier { trackName: trackName, artistName: artistName, progress: notifProgress, - total: notifTotal > 0 ? notifTotal : 1, - ); + total: notifTotal > 0 ? notifTotal : 1, + ); if (Platform.isAndroid) { PlatformBridge.updateDownloadServiceProgress( @@ -839,27 +842,35 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } - Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { + Future _buildOutputDir( + Track track, + String folderOrganization, { + bool separateSingles = false, + String albumFolderStructure = 'artist_album', + }) async { String baseDir = state.outputDir; - final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; + final albumArtist = + _normalizeOptionalString(track.albumArtist) ?? track.artistName; if (separateSingles) { final isSingle = track.isSingle; final artistName = _sanitizeFolderName(albumArtist); - + if (albumFolderStructure == 'artist_album_singles') { if (isSingle) { - final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles'; + final singlesPath = + '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles'; await _ensureDirExists(singlesPath, label: 'Artist Singles folder'); return singlesPath; } else { final albumName = _sanitizeFolderName(track.albumName); - final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + final albumPath = + '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; await _ensureDirExists(albumPath, label: 'Artist Album folder'); return albumPath; } } - + if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; await _ensureDirExists(singlesPath, label: 'Singles folder'); @@ -868,23 +879,27 @@ class DownloadQueueNotifier extends Notifier { final albumName = _sanitizeFolderName(track.albumName); final year = _extractYear(track.releaseDate); String albumPath; - + switch (albumFolderStructure) { case 'album_only': - albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName'; + albumPath = + '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName'; break; case 'artist_year_album': final yearAlbum = year != null ? '[$year] $albumName' : albumName; - albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum'; + albumPath = + '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum'; break; case 'year_album': final yearAlbum = year != null ? '[$year] $albumName' : albumName; - albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum'; + albumPath = + '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum'; break; default: - albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + albumPath = + '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; } - + await _ensureDirExists(albumPath, label: 'Album folder'); return albumPath; } @@ -950,7 +965,8 @@ class DownloadQueueNotifier extends Notifier { bool separateSingles = false, String albumFolderStructure = 'artist_album', }) async { - final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; + final albumArtist = + _normalizeOptionalString(track.albumArtist) ?? track.artistName; if (separateSingles) { final isSingle = track.isSingle; @@ -1250,7 +1266,7 @@ class DownloadQueueNotifier extends Notifier { } } -void removeItem(String id) { + void removeItem(String id) { final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); _saveQueueToStorage(); @@ -1281,7 +1297,8 @@ void removeItem(String id) { // Use date-only format for daily grouping (YYYY-MM-DD) final now = DateTime.now(); - final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final fileName = 'failed_downloads_$dateStr.txt'; final filePath = '$failedDownloadsDir/$fileName'; @@ -1289,7 +1306,7 @@ void removeItem(String id) { final bool fileExists = await file.exists(); final buffer = StringBuffer(); - + if (!fileExists) { buffer.writeln('# SpotiFLAC Failed Downloads'); buffer.writeln('# Date: $dateStr'); @@ -1298,15 +1315,18 @@ void removeItem(String id) { buffer.writeln(''); } - final timeStr = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; - + final timeStr = + '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + for (final item in failedItems) { final track = item.track; final spotifyUrl = track.id.startsWith('deezer:') ? 'https://www.deezer.com/track/${track.id.substring(7)}' : 'https://open.spotify.com/track/${track.id}'; final error = item.error ?? 'Unknown error'; - buffer.writeln('[$timeStr] ${track.name} - ${track.artistName} | $spotifyUrl | $error'); + buffer.writeln( + '[$timeStr] ${track.name} - ${track.artistName} | $spotifyUrl | $error', + ); } if (fileExists) { @@ -1337,21 +1357,22 @@ void removeItem(String id) { try { final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); - + if (!settings.useExtensionProviders) return; - + final hasPostProcessing = extensionState.extensions.any( (e) => e.enabled && e.hasPostProcessing, ); if (!hasPostProcessing) return; - + _log.d('Running post-processing hooks on: $filePath'); - + final metadata = { 'title': track.name, 'artist': track.artistName, 'album': track.albumName, - 'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName, + 'album_artist': + _normalizeOptionalString(track.albumArtist) ?? track.artistName, 'track_number': track.trackNumber ?? 1, 'disc_number': track.discNumber ?? 1, 'isrc': track.isrc ?? '', @@ -1359,17 +1380,17 @@ void removeItem(String id) { 'duration_ms': track.duration * 1000, 'cover_url': track.coverUrl ?? '', }; - + final result = await PlatformBridge.runPostProcessingV2( filePath, metadata: metadata, ); - + if (result['success'] == true) { final hooksRun = result['hooks_run'] as int? ?? 0; final newPath = result['file_path'] as String?; _log.i('Post-processing completed: $hooksRun hook(s) executed'); - + if (newPath != null && newPath != filePath) { _log.d('File path changed by post-processing: $newPath'); } @@ -1386,28 +1407,77 @@ void removeItem(String id) { const spotifySize300 = 'ab67616d00001e02'; const spotifySize640 = 'ab67616d0000b273'; const spotifySizeMax = 'ab67616d000082c1'; - + var result = coverUrl; if (result.contains(spotifySize300)) { result = result.replaceFirst(spotifySize300, spotifySize640); } - + if (result.contains(spotifySize640)) { result = result.replaceFirst(spotifySize640, spotifySizeMax); } - + return result; } + int? _parsePositiveInt(dynamic value) { + if (value is int && value > 0) return value; + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null && parsed > 0) return parsed; + } + return null; + } + + Track _buildTrackForMetadataEmbedding( + Track baseTrack, + Map backendResult, + String? normalizedAlbumArtist, + ) { + final backendTrackNum = _parsePositiveInt(backendResult['track_number']); + final backendDiscNum = _parsePositiveInt(backendResult['disc_number']); + final backendYear = _normalizeOptionalString( + backendResult['release_date'] as String?, + ); + final backendAlbum = _normalizeOptionalString( + backendResult['album'] as String?, + ); + + if (backendTrackNum == null && + backendDiscNum == null && + backendYear == null && + backendAlbum == null) { + return baseTrack; + } + + return Track( + id: baseTrack.id, + name: baseTrack.name, + artistName: baseTrack.artistName, + albumName: backendAlbum ?? baseTrack.albumName, + albumArtist: normalizedAlbumArtist, + coverUrl: baseTrack.coverUrl, + duration: baseTrack.duration, + isrc: baseTrack.isrc, + trackNumber: backendTrackNum ?? baseTrack.trackNumber, + discNumber: backendDiscNum ?? baseTrack.discNumber, + releaseDate: backendYear ?? baseTrack.releaseDate, + deezerId: baseTrack.deezerId, + availability: baseTrack.availability, + albumType: baseTrack.albumType, + source: baseTrack.source, + ); + } + Future _embedMetadataAndCover( - String flacPath, + String flacPath, Track track, { String? genre, String? label, String? copyright, }) async { final settings = ref.read(settingsProvider); - + String? coverPath; var coverUrl = track.coverUrl; if (coverUrl != null && coverUrl.isNotEmpty) { @@ -1416,7 +1486,7 @@ void removeItem(String id) { coverUrl = _upgradeToMaxQualityCover(coverUrl); _log.d('Cover URL upgraded to max quality: $coverUrl'); } - + final tempDir = await getTemporaryDirectory(); final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; @@ -1449,8 +1519,8 @@ void removeItem(String id) { 'ALBUM': track.albumName, }; - final albumArtist = _normalizeOptionalString(track.albumArtist) ?? - track.artistName; + final albumArtist = + _normalizeOptionalString(track.albumArtist) ?? track.artistName; metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { @@ -1489,7 +1559,7 @@ void removeItem(String id) { try { final durationMs = track.duration * 1000; - + final lrcContent = await PlatformBridge.getLyricsLRC( track.id, track.name, @@ -1541,14 +1611,14 @@ void removeItem(String id) { } Future _embedMetadataToMp3( - String mp3Path, + String mp3Path, Track track, { String? genre, String? label, String? copyright, }) async { final settings = ref.read(settingsProvider); - + String? coverPath; var coverUrl = track.coverUrl; if (coverUrl != null && coverUrl.isNotEmpty) { @@ -1557,7 +1627,7 @@ void removeItem(String id) { coverUrl = _upgradeToMaxQualityCover(coverUrl); _log.d('Cover URL upgraded to max quality for MP3: $coverUrl'); } - + final tempDir = await getTemporaryDirectory(); final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; @@ -1573,7 +1643,9 @@ void removeItem(String id) { await sink.close(); _log.d('Cover downloaded for MP3: $coverPath'); } else { - _log.w('Failed to download cover for MP3: HTTP ${response.statusCode}'); + _log.w( + 'Failed to download cover for MP3: HTTP ${response.statusCode}', + ); coverPath = null; } httpClient.close(); @@ -1590,8 +1662,8 @@ void removeItem(String id) { 'ALBUM': track.albumName, }; - final albumArtist = _normalizeOptionalString(track.albumArtist) ?? - track.artistName; + final albumArtist = + _normalizeOptionalString(track.albumArtist) ?? track.artistName; metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { @@ -1630,12 +1702,13 @@ void removeItem(String id) { final lyricsMode = settings.lyricsMode; final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both'; - final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both'; - + final shouldSaveExternal = + lyricsMode == 'external' || lyricsMode == 'both'; + if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) { try { final durationMs = track.duration * 1000; - + final lrcContent = await PlatformBridge.getLyricsLRC( track.id, track.name, @@ -1648,12 +1721,17 @@ void removeItem(String id) { if (shouldEmbed) { metadata['LYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)'); + _log.d( + 'Lyrics fetched for MP3 embedding (${lrcContent.length} chars)', + ); } - + if (shouldSaveExternal) { try { - final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc'); + final lrcPath = mp3Path.replaceAll( + RegExp(r'\.mp3$', caseSensitive: false), + '.lrc', + ); await File(lrcPath).writeAsString(lrcContent); _log.d('External LRC file saved: $lrcPath'); } catch (e) { @@ -1698,14 +1776,14 @@ void removeItem(String id) { } Future _embedMetadataToOpus( - String opusPath, + String opusPath, Track track, { String? genre, String? label, String? copyright, }) async { final settings = ref.read(settingsProvider); - + String? coverPath; var coverUrl = track.coverUrl; if (coverUrl != null && coverUrl.isNotEmpty) { @@ -1714,7 +1792,7 @@ void removeItem(String id) { coverUrl = _upgradeToMaxQualityCover(coverUrl); _log.d('Cover URL upgraded to max quality for Opus: $coverUrl'); } - + final tempDir = await getTemporaryDirectory(); final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; @@ -1730,7 +1808,9 @@ void removeItem(String id) { await sink.close(); _log.d('Cover downloaded for Opus: $coverPath'); } else { - _log.w('Failed to download cover for Opus: HTTP ${response.statusCode}'); + _log.w( + 'Failed to download cover for Opus: HTTP ${response.statusCode}', + ); coverPath = null; } httpClient.close(); @@ -1747,8 +1827,8 @@ void removeItem(String id) { 'ALBUM': track.albumName, }; - final albumArtist = _normalizeOptionalString(track.albumArtist) ?? - track.artistName; + final albumArtist = + _normalizeOptionalString(track.albumArtist) ?? track.artistName; metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { @@ -1784,12 +1864,13 @@ void removeItem(String id) { final lyricsMode = settings.lyricsMode; final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both'; - final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both'; - + final shouldSaveExternal = + lyricsMode == 'external' || lyricsMode == 'both'; + if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) { try { final durationMs = track.duration * 1000; - + final lrcContent = await PlatformBridge.getLyricsLRC( track.id, track.name, @@ -1801,12 +1882,17 @@ void removeItem(String id) { if (lrcContent.isNotEmpty) { if (shouldEmbed) { metadata['LYRICS'] = lrcContent; - _log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)'); + _log.d( + 'Lyrics fetched for Opus embedding (${lrcContent.length} chars)', + ); } - + if (shouldSaveExternal) { try { - final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc'); + final lrcPath = opusPath.replaceAll( + RegExp(r'\.opus$', caseSensitive: false), + '.lrc', + ); await File(lrcPath).writeAsString(lrcContent); _log.d('External LRC file saved: $lrcPath'); } catch (e) { @@ -1920,7 +2006,7 @@ void removeItem(String id) { } } -Future _processQueue() async { + Future _processQueue() async { if (state.isProcessing) return; // Check network connectivity before starting @@ -1969,11 +2055,14 @@ Future _processQueue() async { // iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access) if (!isSafMode && Platform.isIOS && state.outputDir.isNotEmpty) { - final isICloudPath = state.outputDir.contains('Mobile Documents') || + final isICloudPath = + state.outputDir.contains('Mobile Documents') || state.outputDir.contains('CloudDocs') || state.outputDir.contains('com~apple~CloudDocs'); if (isICloudPath) { - _log.w('iOS: iCloud Drive path detected, falling back to app Documents folder'); + _log.w( + 'iOS: iCloud Drive path detected, falling back to app Documents folder', + ); _log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing'); final dir = await getApplicationDocumentsDirectory(); final musicDir = Directory('${dir.path}/SpotiFLAC'); @@ -2022,7 +2111,8 @@ Future _processQueue() async { updateItemStatus( item.id, DownloadStatus.failed, - error: 'SAF permission invalid or revoked. Please reconfigure download location in Settings.', + error: + 'SAF permission invalid or revoked. Please reconfigure download location in Settings.', ); } } @@ -2059,7 +2149,7 @@ Future _processQueue() async { _downloadCount = 0; } -_log.i( + _log.i( 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', ); if (_totalQueuedAtStart > 0) { @@ -2129,7 +2219,6 @@ _log.i( final maxConcurrent = state.concurrentDownloads; final activeDownloads = >{}; - _startMultiProgressPolling(); while (true) { @@ -2254,7 +2343,9 @@ _log.i( releaseDate: data['release_date'] as String?, deezerId: rawId, availability: trackToDownload.availability, - albumType: (data['album_type'] as String?) ?? trackToDownload.albumType, + albumType: + (data['album_type'] as String?) ?? + trackToDownload.albumType, source: trackToDownload.source, ); _log.d( @@ -2274,8 +2365,9 @@ _log.i( _log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}'); - final normalizedAlbumArtist = - _normalizeOptionalString(trackToDownload.albumArtist); + final normalizedAlbumArtist = _normalizeOptionalString( + trackToDownload.albumArtist, + ); final quality = item.qualityOverride ?? state.audioQuality; final isSafMode = _isSafMode(settings); @@ -2303,17 +2395,15 @@ _log.i( String? safBaseName; String safOutputExt = _determineOutputExt(quality, item.service); if (isSafMode) { - final baseName = await PlatformBridge.buildFilename( - state.filenameFormat, - { - 'title': trackToDownload.name, - 'artist': trackToDownload.artistName, - 'album': trackToDownload.albumName, - 'track': trackToDownload.trackNumber ?? 0, - 'disc': trackToDownload.discNumber ?? 0, - 'year': _extractYear(trackToDownload.releaseDate) ?? '', - }, - ); + final baseName = + await PlatformBridge.buildFilename(state.filenameFormat, { + 'title': trackToDownload.name, + 'artist': trackToDownload.artistName, + 'album': trackToDownload.albumName, + 'track': trackToDownload.trackNumber ?? 0, + 'disc': trackToDownload.discNumber ?? 0, + 'year': _extractYear(trackToDownload.releaseDate) ?? '', + }); final sanitized = await PlatformBridge.sanitizeFilename(baseName); safBaseName = sanitized; safFileName = '$sanitized$safOutputExt'; @@ -2322,20 +2412,26 @@ _log.i( String? genre; String? label; - + String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { deezerTrackId = trackToDownload.id.split(':')[1]; } - if (deezerTrackId == null && trackToDownload.availability?.deezerId != null) { + if (deezerTrackId == null && + trackToDownload.availability?.deezerId != null) { deezerTrackId = trackToDownload.availability!.deezerId; } - - if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) { + + if (deezerTrackId == null && + trackToDownload.isrc != null && + trackToDownload.isrc!.isNotEmpty) { try { _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); - final deezerResult = await PlatformBridge.searchDeezerByISRC(trackToDownload.isrc!); - if (deezerResult['success'] == true && deezerResult['track_id'] != null) { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + trackToDownload.isrc!, + ); + if (deezerResult['success'] == true && + deezerResult['track_id'] != null) { deezerTrackId = deezerResult['track_id'].toString(); _log.d('Found Deezer track ID via ISRC: $deezerTrackId'); } @@ -2343,10 +2439,11 @@ _log.i( _log.w('Failed to search Deezer by ISRC: $e'); } } - + if (deezerTrackId != null && deezerTrackId.isNotEmpty) { try { - final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); + final extendedMetadata = + await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); if (extendedMetadata != null) { genre = extendedMetadata['genre']; label = extendedMetadata['label']; @@ -2362,8 +2459,11 @@ _log.i( Map result; final extensionState = ref.read(extensionProvider); - final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled); - final useExtensions = settings.useExtensionProviders && hasActiveExtensions; + final hasActiveExtensions = extensionState.extensions.any( + (e) => e.enabled, + ); + final useExtensions = + settings.useExtensionProviders && hasActiveExtensions; Future> runDownload({ required bool useSaf, @@ -2515,16 +2615,18 @@ _log.i( if (result['success'] == true) { var filePath = result['file_path'] as String?; final reportedFileName = result['file_name'] as String?; - if (effectiveSafMode && reportedFileName != null && reportedFileName.isNotEmpty) { + if (effectiveSafMode && + reportedFileName != null && + reportedFileName.isNotEmpty) { finalSafFileName = reportedFileName; } - + // Check if file already existed (detected via ISRC match in Go backend) final wasExisting = result['already_exists'] == true; if (wasExisting) { _log.i('File already exists in library: $filePath'); } - + _log.i('Download success, file: $filePath'); final actualBitDepth = result['actual_bit_depth'] as int?; @@ -2542,20 +2644,45 @@ _log.i( _log.i('Actual quality: $actualQuality'); } + final actualService = + ((result['service'] as String?)?.toLowerCase()) ?? + item.service.toLowerCase(); final isContentUriPath = filePath != null && isContentUri(filePath); - final mimeType = isContentUriPath ? await _getSafMimeType(filePath) : null; - final isM4aFile = filePath != null && + final mimeType = isContentUriPath + ? await _getSafMimeType(filePath) + : null; + final isM4aFile = + filePath != null && (filePath.endsWith('.m4a') || (mimeType != null && mimeType.contains('mp4'))); + final isFlacFile = + filePath != null && + (filePath.endsWith('.flac') || + (mimeType != null && mimeType.contains('flac'))); + final shouldForceTidalSafM4aHandling = + isContentUriPath && + effectiveSafMode && + actualService == 'tidal' && + quality != 'HIGH' && + filePath.endsWith('.flac') && + (mimeType == null || mimeType.contains('flac')); - if (isM4aFile) { - // At this point filePath is guaranteed non-null by isM4aFile check + if (shouldForceTidalSafM4aHandling) { + _log.w( + 'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; forcing FFmpeg conversion to FLAC.', + ); + } + + if (isM4aFile || shouldForceTidalSafM4aHandling) { + // At this point filePath is guaranteed non-null by the checks above. final currentFilePath = filePath; - + if (isContentUriPath && effectiveSafMode) { if (quality == 'HIGH') { final tidalHighFormat = settings.tidalHighFormat; - _log.i('Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...'); + _log.i( + 'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...', + ); final tempPath = await _copySafToTemp(currentFilePath); if (tempPath != null) { @@ -2567,7 +2694,9 @@ _log.i( progress: 0.95, ); - final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final format = tidalHighFormat.startsWith('opus') + ? 'opus' + : 'mp3'; convertedPath = await FFmpegService.convertM4aToLossy( tempPath, format: format, @@ -2576,7 +2705,9 @@ _log.i( ); if (convertedPath != null) { - _log.i('Successfully converted M4A to $format (temp): $convertedPath'); + _log.i( + 'Successfully converted M4A to $format (temp): $convertedPath', + ); _log.i('Embedding metadata to $format...'); updateItemStatus( item.id, @@ -2617,7 +2748,9 @@ _log.i( ); if (newUri != null) { - await _deleteSafFile(currentFilePath); + if (newUri != currentFilePath) { + await _deleteSafFile(currentFilePath); + } filePath = newUri; finalSafFileName = newFileName; final bitrateDisplay = tidalHighFormat.contains('_') @@ -2625,11 +2758,15 @@ _log.i( : '320kbps'; actualQuality = '${format.toUpperCase()} $bitrateDisplay'; } else { - _log.w('Failed to write converted $format to SAF, keeping M4A'); + _log.w( + 'Failed to write converted $format to SAF, keeping M4A', + ); actualQuality = 'AAC 320kbps'; } } else { - _log.w('M4A to $format conversion failed, keeping M4A file'); + _log.w( + 'M4A to $format conversion failed, keeping M4A file', + ); actualQuality = 'AAC 320kbps'; } } catch (e) { @@ -2637,9 +2774,13 @@ _log.i( actualQuality = 'AAC 320kbps'; } finally { // Clean up temp files - try { await File(tempPath).delete(); } catch (_) {} + try { + await File(tempPath).delete(); + } catch (_) {} if (convertedPath != null) { - try { await File(convertedPath).delete(); } catch (_) {} + try { + await File(convertedPath).delete(); + } catch (_) {} } } } @@ -2661,43 +2802,14 @@ _log.i( flacPath = await FFmpegService.convertM4aToFlac(tempPath); if (flacPath != null) { _log.d('Converted to FLAC (temp): $flacPath'); - _log.d('Embedding metadata and cover to converted FLAC...'); - - Track finalTrack = trackToDownload; - if (result.containsKey('track_number') || - result.containsKey('release_date')) { - final backendTrackNum = result['track_number'] as int?; - final backendDiscNum = result['disc_number'] as int?; - final backendYear = result['release_date'] as String?; - final backendAlbum = result['album'] as String?; - - final newTrackNumber = - (backendTrackNum != null && backendTrackNum > 0) - ? backendTrackNum - : trackToDownload.trackNumber; - final newDiscNumber = - (backendDiscNum != null && backendDiscNum > 0) - ? backendDiscNum - : trackToDownload.discNumber; - - finalTrack = Track( - id: trackToDownload.id, - name: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: backendAlbum ?? trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - duration: trackToDownload.duration, - isrc: trackToDownload.isrc, - trackNumber: newTrackNumber, - discNumber: newDiscNumber, - releaseDate: backendYear ?? trackToDownload.releaseDate, - deezerId: trackToDownload.deezerId, - availability: trackToDownload.availability, - albumType: trackToDownload.albumType, - source: trackToDownload.source, - ); - } + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + normalizedAlbumArtist, + ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; @@ -2721,23 +2833,31 @@ _log.i( ); if (newUri != null) { - await _deleteSafFile(currentFilePath); + if (newUri != currentFilePath) { + await _deleteSafFile(currentFilePath); + } filePath = newUri; finalSafFileName = newFileName; } else { _log.w('Failed to write FLAC to SAF, keeping M4A'); } } else { - _log.w('FFmpeg conversion returned null, keeping M4A file'); + _log.w( + 'FFmpeg conversion returned null, keeping M4A file', + ); } } } catch (e) { _log.w('SAF M4A->FLAC conversion failed: $e'); } finally { // Clean up temp files - try { await File(tempPath).delete(); } catch (_) {} + try { + await File(tempPath).delete(); + } catch (_) {} if (flacPath != null) { - try { await File(flacPath).delete(); } catch (_) {} + try { + await File(flacPath).delete(); + } catch (_) {} } } } @@ -2746,7 +2866,9 @@ _log.i( // Local file path flow (original) if (quality == 'HIGH') { final tidalHighFormat = settings.tidalHighFormat; - _log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...'); + _log.i( + 'Tidal HIGH quality download, converting M4A to $tidalHighFormat...', + ); try { updateItemStatus( @@ -2755,7 +2877,9 @@ _log.i( progress: 0.95, ); - final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final format = tidalHighFormat.startsWith('opus') + ? 'opus' + : 'mp3'; final convertedPath = await FFmpegService.convertM4aToLossy( currentFilePath, format: format, @@ -2769,7 +2893,9 @@ _log.i( ? '${tidalHighFormat.split('_').last}kbps' : '320kbps'; actualQuality = '${format.toUpperCase()} $bitrateDisplay'; - _log.i('Successfully converted M4A to $format: $convertedPath'); + _log.i( + 'Successfully converted M4A to $format: $convertedPath', + ); _log.i('Embedding metadata to $format...'); updateItemStatus( @@ -2831,67 +2957,34 @@ _log.i( DownloadStatus.downloading, progress: 0.95, ); - final flacPath = await FFmpegService.convertM4aToFlac(currentFilePath); + final flacPath = await FFmpegService.convertM4aToFlac( + currentFilePath, + ); if (flacPath != null) { filePath = flacPath; _log.d('Converted to FLAC: $flacPath'); - _log.d('Embedding metadata and cover to converted FLAC...'); + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); try { - Track finalTrack = trackToDownload; - if (result.containsKey('track_number') || - result.containsKey('release_date')) { - _log.d( - 'Using metadata from backend response for embedding', - ); - final backendTrackNum = result['track_number'] as int?; - final backendDiscNum = result['disc_number'] as int?; - final backendYear = result['release_date'] as String?; - final backendAlbum = result['album'] as String?; - - _log.d( - 'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear', - ); - - final newTrackNumber = - (backendTrackNum != null && backendTrackNum > 0) - ? backendTrackNum - : trackToDownload.trackNumber; - final newDiscNumber = - (backendDiscNum != null && backendDiscNum > 0) - ? backendDiscNum - : trackToDownload.discNumber; - - _log.d( - 'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber', - ); - - finalTrack = Track( - id: trackToDownload.id, - name: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: backendAlbum ?? trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - duration: trackToDownload.duration, - isrc: trackToDownload.isrc, - trackNumber: newTrackNumber, - discNumber: newDiscNumber, - releaseDate: backendYear ?? trackToDownload.releaseDate, - deezerId: trackToDownload.deezerId, - availability: trackToDownload.availability, - albumType: trackToDownload.albumType, - source: trackToDownload.source, - ); - } + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + normalizedAlbumArtist, + ); final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - if (backendGenre != null || backendLabel != null || backendCopyright != null) { - _log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright'); + if (backendGenre != null || + backendLabel != null || + backendCopyright != null) { + _log.d( + 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', + ); } await _embedMetadataAndCover( @@ -2906,15 +2999,80 @@ _log.i( _log.w('Warning: Failed to embed metadata/cover: $e'); } } else { - _log.w('FFmpeg conversion returned null, keeping M4A file'); + _log.w( + 'FFmpeg conversion returned null, keeping M4A file', + ); } } } } catch (e) { - _log.w('FFmpeg conversion process failed: $e, keeping M4A file'); + _log.w( + 'FFmpeg conversion process failed: $e, keeping M4A file', + ); } } } + } else if (isContentUriPath && + effectiveSafMode && + isFlacFile && + !wasExisting) { + final currentFilePath = filePath; + _log.d( + 'SAF FLAC detected, embedding metadata and cover via temp file...', + ); + final tempPath = await _copySafToTemp(currentFilePath); + if (tempPath != null) { + try { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + normalizedAlbumArtist, + ); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + await _embedMetadataAndCover( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + + final newFileName = '${safBaseName ?? 'track'}.flac'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt('.flac'), + srcPath: tempPath, + ); + + if (newUri != null) { + if (newUri != currentFilePath) { + await _deleteSafFile(currentFilePath); + } + filePath = newUri; + finalSafFileName = newFileName; + _log.d('SAF FLAC metadata embedding completed'); + } else { + _log.w('Failed to write metadata-updated FLAC back to SAF'); + } + } catch (e) { + _log.w('SAF FLAC metadata embedding failed: $e'); + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + } + } } final itemAfterDownload = state.items.firstWhere( @@ -2939,7 +3097,8 @@ _log.i( final lyricsMode = settings.lyricsMode; final shouldSaveExternalLrc = - settings.embedLyrics && (lyricsMode == 'external' || lyricsMode == 'both'); + settings.embedLyrics && + (lyricsMode == 'external' || lyricsMode == 'both'); if (shouldSaveExternalLrc && effectiveSafMode && filePath != null && @@ -2962,9 +3121,9 @@ _log.i( final baseName = finalSafFileName != null ? finalSafFileName.replaceFirst(RegExp(r'\.[^.]+$'), '') : safBaseName ?? - await PlatformBridge.sanitizeFilename( - '${trackToDownload.artistName} - ${trackToDownload.name}', - ); + await PlatformBridge.sanitizeFilename( + '${trackToDownload.artistName} - ${trackToDownload.name}', + ); await _writeLrcToSaf( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, @@ -2979,11 +3138,14 @@ _log.i( } _completedInSession++; - + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - final existingInHistory = historyNotifier.getBySpotifyId(trackToDownload.id) ?? - (trackToDownload.isrc != null ? historyNotifier.getByIsrc(trackToDownload.isrc!) : null); - + final existingInHistory = + historyNotifier.getBySpotifyId(trackToDownload.id) ?? + (trackToDownload.isrc != null + ? historyNotifier.getByIsrc(trackToDownload.isrc!) + : null); + if (wasExisting && existingInHistory != null) { _log.i('Track already in library, skipping history update'); await _notificationService.showDownloadComplete( @@ -2996,7 +3158,7 @@ _log.i( removeItem(item.id); return; } - + await _notificationService.showDownloadComplete( trackName: item.track.name, artistName: item.track.artistName, @@ -3023,9 +3185,9 @@ _log.i( final historyAlbumArtist = (normalizedAlbumArtist != null && - normalizedAlbumArtist != trackToDownload.artistName) - ? normalizedAlbumArtist - : null; + normalizedAlbumArtist != trackToDownload.artistName) + ? normalizedAlbumArtist + : null; final isMp3 = filePath.endsWith('.mp3'); final historyBitDepth = isMp3 ? null : backendBitDepth; @@ -3039,7 +3201,8 @@ _log.i( trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : trackToDownload.name, - artistName: (backendArtist != null && backendArtist.isNotEmpty) + artistName: + (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : trackToDownload.artistName, albumName: (backendAlbum != null && backendAlbum.isNotEmpty) @@ -3049,9 +3212,13 @@ _log.i( coverUrl: trackToDownload.coverUrl, filePath: filePath, storageMode: effectiveSafMode ? 'saf' : 'app', - downloadTreeUri: effectiveSafMode ? settings.downloadTreeUri : null, + downloadTreeUri: effectiveSafMode + ? settings.downloadTreeUri + : null, safRelativeDir: effectiveSafMode ? effectiveOutputDir : null, - safFileName: effectiveSafMode ? (finalSafFileName ?? safFileName) : null, + safFileName: effectiveSafMode + ? (finalSafFileName ?? safFileName) + : null, safRepaired: false, service: result['service'] as String? ?? item.service, downloadedAt: DateTime.now(), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 84b872ac..335e2f51 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -15,7 +15,8 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { const DownloadSettingsPage({super.key}); @override - ConsumerState createState() => _DownloadSettingsPageState(); + ConsumerState createState() => + _DownloadSettingsPageState(); } class _DownloadSettingsPageState extends ConsumerState { @@ -93,7 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState { final settings = ref.watch(settingsProvider); final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - + final isBuiltInService = _builtInServices.contains(settings.defaultService); final isTidalService = settings.defaultService == 'tidal'; @@ -103,43 +104,43 @@ class _DownloadSettingsPageState extends ConsumerState { body: CustomScrollView( slivers: [ SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), - title: Text( - context.l10n.downloadTitle, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, ), - ), - ); - }, + title: Text( + context.l10n.downloadTitle, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), ), - ), SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionService), @@ -158,7 +159,9 @@ class _DownloadSettingsPageState extends ConsumerState { ), SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality), + child: SettingsSectionHeader( + title: context.l10n.sectionAudioQuality, + ), ), SliverToBoxAdapter( child: SettingsGroup( @@ -166,7 +169,7 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsSwitchItem( icon: Icons.tune, title: context.l10n.downloadAskBeforeDownload, - subtitle: isBuiltInService + subtitle: isBuiltInService ? context.l10n.downloadAskQualitySubtitle : 'Select a built-in service to enable', value: settings.askQualityBeforeDownload, @@ -175,7 +178,8 @@ class _DownloadSettingsPageState extends ConsumerState { .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), ), - if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ + if (!settings.askQualityBeforeDownload && + isBuiltInService) ...[ _QualityOption( title: context.l10n.qualityFlacLossless, subtitle: context.l10n.qualityFlacLosslessSubtitle, @@ -205,7 +209,9 @@ class _DownloadSettingsPageState extends ConsumerState { if (isTidalService) _QualityOption( title: 'Lossy 320kbps', - subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat), + subtitle: _getTidalHighFormatLabel( + settings.tidalHighFormat, + ), isSelected: settings.audioQuality == 'HIGH', onTap: () => ref .read(settingsProvider.notifier) @@ -216,8 +222,14 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsItem( icon: Icons.tune, title: 'Lossy Format', - subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat), - onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat), + subtitle: _getTidalHighFormatLabel( + settings.tidalHighFormat, + ), + onTap: () => _showTidalHighFormatPicker( + context, + ref, + settings.tidalHighFormat, + ), showDivider: false, ), ], @@ -235,43 +247,46 @@ class _DownloadSettingsPageState extends ConsumerState { Expanded( child: Text( 'Select Tidal, Qobuz, or Amazon above to configure quality', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), ], ), ), + ], ], - ], + ), ), - ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionLyrics), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsItem( - icon: Icons.lyrics_outlined, - title: context.l10n.lyricsMode, - subtitle: _getLyricsModeLabel(context, settings.lyricsMode), - onTap: () => _showLyricsModePicker( - context, - ref, - settings.lyricsMode, + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.sectionLyrics), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.lyrics_outlined, + title: context.l10n.lyricsMode, + subtitle: _getLyricsModeLabel(context, settings.lyricsMode), + onTap: () => _showLyricsModePicker( + context, + ref, + settings.lyricsMode, + ), + showDivider: false, ), - showDivider: false, - ), - ], + ], + ), ), - ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionFileSettings), - ), + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.sectionFileSettings, + ), + ), SliverToBoxAdapter( child: SettingsGroup( children: [ @@ -310,7 +325,9 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsItem( icon: Icons.folder_outlined, title: context.l10n.downloadAlbumFolderStructure, - subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure), + subtitle: _getAlbumFolderStructureLabel( + settings.albumFolderStructure, + ), onTap: () => _showAlbumFolderStructurePicker( context, ref, @@ -348,7 +365,11 @@ class _DownloadSettingsPageState extends ConsumerState { subtitle: settings.downloadNetworkMode == 'wifi_only' ? context.l10n.settingsDownloadNetworkWifiOnly : context.l10n.settingsDownloadNetworkAny, - onTap: () => _showNetworkModePicker(context, ref, settings.downloadNetworkMode), + onTap: () => _showNetworkModePicker( + context, + ref, + settings.downloadNetworkMode, + ), ), SettingsSwitchItem( icon: Icons.file_download_outlined, @@ -356,7 +377,9 @@ class _DownloadSettingsPageState extends ConsumerState { subtitle: context.l10n.settingsAutoExportFailedSubtitle, value: settings.autoExportFailedDownloads, onChanged: (value) { - ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value); + ref + .read(settingsProvider.notifier) + .setAutoExportFailedDownloads(value); }, showDivider: false, ), @@ -367,7 +390,9 @@ class _DownloadSettingsPageState extends ConsumerState { // All Files Access section (Android 13+ only) if (Platform.isAndroid && _androidSdkVersion >= 33) ...[ SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess), + child: SettingsSectionHeader( + title: context.l10n.sectionStorageAccess, + ), ), SliverToBoxAdapter( child: SettingsGroup( @@ -406,16 +431,15 @@ class _DownloadSettingsPageState extends ConsumerState { Expanded( child: Text( context.l10n.allFilesAccessDescription, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), ], ), ), ), -], + ], const SliverToBoxAdapter(child: SizedBox(height: 32)), ], @@ -439,7 +463,11 @@ class _DownloadSettingsPageState extends ConsumerState { } } - void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) { + void _showAlbumFolderStructurePicker( + BuildContext context, + WidgetRef ref, + String current, + ) { showModalBottomSheet( context: context, builder: (context) => SafeArea( @@ -450,9 +478,13 @@ class _DownloadSettingsPageState extends ConsumerState { leading: const Icon(Icons.folder_outlined), title: Text(context.l10n.albumFolderArtistAlbum), subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle), - trailing: current == 'artist_album' ? const Icon(Icons.check) : null, + trailing: current == 'artist_album' + ? const Icon(Icons.check) + : null, onTap: () { - ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album'); + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('artist_album'); Navigator.pop(context); }, ), @@ -460,9 +492,13 @@ class _DownloadSettingsPageState extends ConsumerState { leading: const Icon(Icons.calendar_today_outlined), title: Text(context.l10n.albumFolderArtistYearAlbum), subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle), - trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null, + trailing: current == 'artist_year_album' + ? const Icon(Icons.check) + : null, onTap: () { - ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album'); + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('artist_year_album'); Navigator.pop(context); }, ), @@ -470,9 +506,13 @@ class _DownloadSettingsPageState extends ConsumerState { leading: const Icon(Icons.album_outlined), title: Text(context.l10n.albumFolderAlbumOnly), subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle), - trailing: current == 'album_only' ? const Icon(Icons.check) : null, + trailing: current == 'album_only' + ? const Icon(Icons.check) + : null, onTap: () { - ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only'); + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('album_only'); Navigator.pop(context); }, ), @@ -480,19 +520,29 @@ class _DownloadSettingsPageState extends ConsumerState { leading: const Icon(Icons.event_outlined), title: Text(context.l10n.albumFolderYearAlbum), subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle), - trailing: current == 'year_album' ? const Icon(Icons.check) : null, + trailing: current == 'year_album' + ? const Icon(Icons.check) + : null, onTap: () { - ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album'); + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('year_album'); Navigator.pop(context); }, ), ListTile( leading: const Icon(Icons.person_outlined), title: Text(context.l10n.albumFolderArtistAlbumSingles), - subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle), - trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null, + subtitle: Text( + context.l10n.albumFolderArtistAlbumSinglesSubtitle, + ), + trailing: current == 'artist_album_singles' + ? const Icon(Icons.check) + : null, onTap: () { - ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles'); + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('artist_album_singles'); Navigator.pop(context); }, ), @@ -635,7 +685,7 @@ class _DownloadSettingsPageState extends ConsumerState { Row( children: [ Expanded( - child: TextButton( + child: TextButton( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -681,21 +731,123 @@ class _DownloadSettingsPageState extends ConsumerState { if (Platform.isIOS) { _showIOSDirectoryOptions(context, ref); } else { - final result = await PlatformBridge.pickSafTree(); - if (result != null) { - final treeUri = result['tree_uri'] as String? ?? ''; - final displayName = result['display_name'] as String? ?? ''; - if (treeUri.isNotEmpty) { - ref.read(settingsProvider.notifier).setStorageMode('saf'); - ref.read(settingsProvider.notifier).setDownloadTreeUri( - treeUri, - displayName: displayName.isNotEmpty ? displayName : treeUri, - ); - } - } + _showAndroidDirectoryOptions(context, ref); } } + Future _getDefaultAndroidDirectory() async { + final directMusicPath = '/storage/emulated/0/Music/SpotiFLAC'; + try { + final musicDir = Directory(directMusicPath); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir.path; + } catch (_) {} + + try { + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + final musicDir = Directory( + '${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC', + ); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir.path; + } + } catch (_) {} + + final appDir = await getApplicationDocumentsDirectory(); + final fallbackDir = Directory('${appDir.path}/SpotiFLAC'); + if (!await fallbackDir.exists()) { + await fallbackDir.create(recursive: true); + } + return fallbackDir.path; + } + + void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final settings = ref.read(settingsProvider); + final isSafMode = + settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Download Location', + style: Theme.of( + ctx, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose storage mode for downloaded files.', + style: Theme.of(ctx).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: Icon(Icons.folder_special, color: colorScheme.primary), + title: const Text('App folder (non-SAF)'), + subtitle: const Text('Use default Music/SpotiFLAC path'), + trailing: !isSafMode ? const Icon(Icons.check) : null, + onTap: () async { + Navigator.pop(ctx); + final defaultDir = await _getDefaultAndroidDirectory(); + final notifier = ref.read(settingsProvider.notifier); + notifier.setStorageMode('app'); + notifier.setDownloadDirectory(defaultDir); + notifier.setDownloadTreeUri(''); + }, + ), + ListTile( + leading: Icon(Icons.folder_open, color: colorScheme.primary), + title: const Text('SAF folder'), + subtitle: const Text( + 'Pick folder via Android Storage Access Framework', + ), + trailing: isSafMode ? const Icon(Icons.check) : null, + onTap: () async { + Navigator.pop(ctx); + final result = await PlatformBridge.pickSafTree(); + if (result != null) { + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isNotEmpty) { + ref.read(settingsProvider.notifier).setStorageMode('saf'); + ref + .read(settingsProvider.notifier) + .setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty + ? displayName + : treeUri, + ); + } + } + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( @@ -740,7 +892,7 @@ class _DownloadSettingsPageState extends ConsumerState { if (ctx.mounted) Navigator.pop(ctx); }, ), -ListTile( + ListTile( leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), title: Text(context.l10n.setupChooseFromFiles), subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), @@ -751,7 +903,8 @@ ListTile( if (result != null) { // iOS: Check if user selected iCloud Drive (not accessible by Go backend) if (Platform.isIOS) { - final isICloudPath = result.contains('Mobile Documents') || + final isICloudPath = + result.contains('Mobile Documents') || result.contains('CloudDocs') || result.contains('com~apple~CloudDocs'); if (isICloudPath) { @@ -956,9 +1109,13 @@ ListTile( leading: const Icon(Icons.audiotrack), title: const Text('MP3 320kbps'), subtitle: const Text('Best compatibility, ~10MB per track'), - trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null, + trailing: current == 'mp3_320' + ? Icon(Icons.check, color: colorScheme.primary) + : null, onTap: () { - ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320'); + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('mp3_320'); Navigator.pop(context); }, ), @@ -966,9 +1123,13 @@ ListTile( leading: const Icon(Icons.graphic_eq), title: const Text('Opus 256kbps'), subtitle: const Text('Best quality Opus, ~8MB per track'), - trailing: current == 'opus_256' ? Icon(Icons.check, color: colorScheme.primary) : null, + trailing: current == 'opus_256' + ? Icon(Icons.check, color: colorScheme.primary) + : null, onTap: () { - ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256'); + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('opus_256'); Navigator.pop(context); }, ), @@ -976,9 +1137,13 @@ ListTile( leading: const Icon(Icons.graphic_eq), title: const Text('Opus 128kbps'), subtitle: const Text('Smallest size, ~4MB per track'), - trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null, + trailing: current == 'opus_128' + ? Icon(Icons.check, color: colorScheme.primary) + : null, onTap: () { - ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128'); + ref + .read(settingsProvider.notifier) + .setTidalHighFormat('opus_128'); Navigator.pop(context); }, ), @@ -1028,9 +1193,13 @@ ListTile( leading: const Icon(Icons.signal_cellular_alt), title: Text(context.l10n.settingsDownloadNetworkAny), subtitle: const Text('WiFi + Mobile Data'), - trailing: current == 'any' ? Icon(Icons.check, color: colorScheme.primary) : null, + trailing: current == 'any' + ? Icon(Icons.check, color: colorScheme.primary) + : null, onTap: () { - ref.read(settingsProvider.notifier).setDownloadNetworkMode('any'); + ref + .read(settingsProvider.notifier) + .setDownloadNetworkMode('any'); Navigator.pop(context); }, ), @@ -1038,9 +1207,13 @@ ListTile( leading: const Icon(Icons.wifi), title: Text(context.l10n.settingsDownloadNetworkWifiOnly), subtitle: const Text('Pause downloads on mobile data'), - trailing: current == 'wifi_only' ? Icon(Icons.check, color: colorScheme.primary) : null, + trailing: current == 'wifi_only' + ? Icon(Icons.check, color: colorScheme.primary) + : null, onTap: () { - ref.read(settingsProvider.notifier).setDownloadNetworkMode('wifi_only'); + ref + .read(settingsProvider.notifier) + .setDownloadNetworkMode('wifi_only'); Navigator.pop(context); }, ), @@ -1097,7 +1270,9 @@ ListTile( example: 'SpotiFLAC/Track.flac', isSelected: current == 'none', onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('none'); + ref + .read(settingsProvider.notifier) + .setFolderOrganization('none'); Navigator.pop(context); }, ), @@ -1107,7 +1282,9 @@ ListTile( example: 'SpotiFLAC/Artist Name/Track.flac', isSelected: current == 'artist', onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('artist'); + ref + .read(settingsProvider.notifier) + .setFolderOrganization('artist'); Navigator.pop(context); }, ), @@ -1117,7 +1294,9 @@ ListTile( example: 'SpotiFLAC/Album Name/Track.flac', isSelected: current == 'album', onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('album'); + ref + .read(settingsProvider.notifier) + .setFolderOrganization('album'); Navigator.pop(context); }, ), @@ -1127,7 +1306,9 @@ ListTile( example: 'SpotiFLAC/Artist/Album/Track.flac', isSelected: current == 'artist_album', onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); + ref + .read(settingsProvider.notifier) + .setFolderOrganization('artist_album'); Navigator.pop(context); }, ), @@ -1151,18 +1332,22 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - + final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) .toList(); - - final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService); - final isCurrentExtensionEnabled = isExtensionService + + final isExtensionService = ![ + 'tidal', + 'qobuz', + 'amazon', + ].contains(currentService); + final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) : true; - + final effectiveService = isCurrentExtensionEnabled ? currentService : ''; - + return Padding( padding: const EdgeInsets.all(12), child: Column( @@ -1263,8 +1448,8 @@ class _ServiceChip extends StatelessWidget { color: isDisabled ? disabledColor : isSelected - ? colorScheme.primaryContainer - : unselectedColor, + ? colorScheme.primaryContainer + : unselectedColor, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: isDisabled ? null : onTap, @@ -1278,8 +1463,8 @@ class _ServiceChip extends StatelessWidget { color: isDisabled ? colorScheme.onSurface.withValues(alpha: 0.38) : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), const SizedBox(height: 6), Text( @@ -1292,8 +1477,8 @@ class _ServiceChip extends StatelessWidget { color: isDisabled ? colorScheme.onSurface.withValues(alpha: 0.38) : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), if (isDisabled && disabledReason != null) diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 5c0bae15..acc2c823 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -10,12 +10,28 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); class FFmpegService { + static String _buildOutputPath(String inputPath, String extension) { + final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; + final inputFile = File(inputPath); + final dir = inputFile.parent.path; + final filename = inputFile.uri.pathSegments.last; + final dotIndex = filename.lastIndexOf('.'); + final baseName = dotIndex > 0 ? filename.substring(0, dotIndex) : filename; + var outputPath = '$dir${Platform.pathSeparator}$baseName$normalizedExt'; + + if (outputPath == inputPath) { + outputPath = + '$dir${Platform.pathSeparator}${baseName}_converted$normalizedExt'; + } + return outputPath; + } + static Future _execute(String command) async { try { final session = await FFmpegKit.execute(command); final returnCode = await session.getReturnCode(); final output = await session.getOutput() ?? ''; - + return FFmpegResult( success: ReturnCode.isSuccess(returnCode), returnCode: returnCode?.getValue() ?? -1, @@ -28,7 +44,7 @@ class FFmpegService { } static Future convertM4aToFlac(String inputPath) async { - final outputPath = inputPath.replaceAll('.m4a', '.flac'); + final outputPath = _buildOutputPath(inputPath, '.flac'); final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y'; @@ -59,10 +75,10 @@ class FFmpegService { bitrateValue = '${parts[1]}k'; } } - + final extension = format == 'opus' ? '.opus' : '.mp3'; - final outputPath = inputPath.replaceAll('.m4a', extension); - + final outputPath = _buildOutputPath(inputPath, extension); + String command; if (format == 'opus') { command = @@ -92,7 +108,7 @@ class FFmpegService { String bitrate = '320k', bool deleteOriginal = true, }) async { - final outputPath = inputPath.replaceAll('.flac', '.mp3'); + final outputPath = _buildOutputPath(inputPath, '.mp3'); final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; @@ -117,7 +133,7 @@ class FFmpegService { String bitrate = '128k', bool deleteOriginal = true, }) async { - final outputPath = inputPath.replaceAll('.flac', '.opus'); + final outputPath = _buildOutputPath(inputPath, '.opus'); final command = '-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y'; @@ -150,15 +166,27 @@ class FFmpegService { bitrateValue = '${parts[1]}k'; } } - + switch (format.toLowerCase()) { case 'opus': - final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k'; - return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal); + final opusBitrate = bitrate?.startsWith('opus_') == true + ? bitrateValue + : '128k'; + return convertFlacToOpus( + inputPath, + bitrate: opusBitrate, + deleteOriginal: deleteOriginal, + ); case 'mp3': default: - final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k'; - return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal); + final mp3Bitrate = bitrate?.startsWith('mp3_') == true + ? bitrateValue + : '320k'; + return convertFlacToMp3( + inputPath, + bitrate: mp3Bitrate, + deleteOriginal: deleteOriginal, + ); } } @@ -168,8 +196,10 @@ class FFmpegService { String bitrate = '256k', }) async { final dir = File(inputPath).parent.path; - final baseName = - inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', ''); + final baseName = inputPath + .split(Platform.pathSeparator) + .last + .replaceAll('.flac', ''); final outputDir = '$dir${Platform.pathSeparator}M4A'; await Directory(outputDir).create(recursive: true); @@ -220,16 +250,16 @@ class FFmpegService { final tempDir = await getTemporaryDirectory(); final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac'; - + final StringBuffer cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$flacPath" '); - + if (coverPath != null) { cmdBuffer.write('-i "$coverPath" '); } - + cmdBuffer.write('-map 0:a '); - + if (coverPath != null) { cmdBuffer.write('-map 1:0 '); cmdBuffer.write('-c:v copy '); @@ -237,18 +267,18 @@ class FFmpegService { cmdBuffer.write('-metadata:s:v title="Album cover" '); cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); } - + cmdBuffer.write('-c:a copy '); - + if (metadata != null) { metadata.forEach((key, value) { final sanitizedValue = value.replaceAll('"', '\\"'); cmdBuffer.write('-metadata $key="$sanitizedValue" '); }); } - + cmdBuffer.write('"$tempOutput" -y'); - + final command = cmdBuffer.toString(); _log.d('Executing FFmpeg command: $command'); @@ -258,20 +288,19 @@ class FFmpegService { try { final tempFile = File(tempOutput); final originalFile = File(flacPath); - - if (await tempFile.exists()) { - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(flacPath); - await tempFile.delete(); - - return flacPath; - } else { - _log.e('Temp output file not found: $tempOutput'); - return null; - } + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(flacPath); + await tempFile.delete(); + + return flacPath; + } else { + _log.e('Temp output file not found: $tempOutput'); + return null; + } } catch (e) { _log.e('Failed to replace file after metadata embed: $e'); return null; @@ -299,16 +328,16 @@ class FFmpegService { final tempDir = await getTemporaryDirectory(); final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3'; - + final StringBuffer cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$mp3Path" '); - + if (coverPath != null) { cmdBuffer.write('-i "$coverPath" '); } - + cmdBuffer.write('-map 0:a '); - + if (coverPath != null) { cmdBuffer.write('-map 1:0 '); cmdBuffer.write('-c:v:0 copy '); @@ -316,9 +345,9 @@ class FFmpegService { cmdBuffer.write('-metadata:s:v title="Album cover" '); cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); } - + cmdBuffer.write('-c:a copy '); - + if (metadata != null) { final id3Metadata = _convertToId3Tags(metadata); id3Metadata.forEach((key, value) { @@ -326,9 +355,9 @@ class FFmpegService { cmdBuffer.write('-metadata $key="$sanitizedValue" '); }); } - + cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); - + final command = cmdBuffer.toString(); _log.d('Executing FFmpeg MP3 embed command: $command'); @@ -338,21 +367,20 @@ class FFmpegService { try { final tempFile = File(tempOutput); final originalFile = File(mp3Path); - - if (await tempFile.exists()) { - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(mp3Path); - await tempFile.delete(); - - _log.d('MP3 metadata embedded successfully'); - return mp3Path; - } else { - _log.e('Temp MP3 output file not found: $tempOutput'); - return null; - } + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(mp3Path); + await tempFile.delete(); + + _log.d('MP3 metadata embedded successfully'); + return mp3Path; + } else { + _log.e('Temp MP3 output file not found: $tempOutput'); + return null; + } } catch (e) { _log.e('Failed to replace MP3 file after metadata embed: $e'); return null; @@ -380,26 +408,28 @@ class FFmpegService { final tempDir = await getTemporaryDirectory(); final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus'; - + final StringBuffer cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$opusPath" '); cmdBuffer.write('-map 0:a '); cmdBuffer.write('-c:a copy '); - + if (metadata != null) { metadata.forEach((key, value) { final sanitizedValue = value.replaceAll('"', '\\"'); cmdBuffer.write('-metadata $key="$sanitizedValue" '); }); } - + if (coverPath != null) { try { final pictureBlock = await _createMetadataBlockPicture(coverPath); if (pictureBlock != null) { final escapedBlock = pictureBlock.replaceAll('"', '\\"'); cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" '); - _log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)'); + _log.d( + 'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)', + ); } else { _log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover'); } @@ -407,9 +437,9 @@ class FFmpegService { _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); } } - + cmdBuffer.write('"$tempOutput" -y'); - + final command = cmdBuffer.toString(); _log.d('Executing FFmpeg Opus embed command'); @@ -419,21 +449,20 @@ class FFmpegService { try { final tempFile = File(tempOutput); final originalFile = File(opusPath); - - if (await tempFile.exists()) { - if (await originalFile.exists()) { - await originalFile.delete(); - } - await tempFile.copy(opusPath); - await tempFile.delete(); - - _log.d('Opus metadata embedded successfully'); - return opusPath; - } else { - _log.e('Temp Opus output file not found: $tempOutput'); - return null; - } + if (await tempFile.exists()) { + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(opusPath); + await tempFile.delete(); + + _log.d('Opus metadata embedded successfully'); + return opusPath; + } else { + _log.e('Temp Opus output file not found: $tempOutput'); + return null; + } } catch (e) { _log.e('Failed to replace Opus file after metadata embed: $e'); return null; @@ -460,81 +489,94 @@ class FFmpegService { _log.e('Cover image not found: $imagePath'); return null; } - + final imageData = await file.readAsBytes(); - + String mimeType; if (imagePath.toLowerCase().endsWith('.png')) { mimeType = 'image/png'; - } else if (imagePath.toLowerCase().endsWith('.jpg') || - imagePath.toLowerCase().endsWith('.jpeg')) { + } else if (imagePath.toLowerCase().endsWith('.jpg') || + imagePath.toLowerCase().endsWith('.jpeg')) { mimeType = 'image/jpeg'; } else { - if (imageData.length >= 8 && - imageData[0] == 0x89 && imageData[1] == 0x50 && - imageData[2] == 0x4E && imageData[3] == 0x47) { + if (imageData.length >= 8 && + imageData[0] == 0x89 && + imageData[1] == 0x50 && + imageData[2] == 0x4E && + imageData[3] == 0x47) { mimeType = 'image/png'; - } else if (imageData.length >= 2 && - imageData[0] == 0xFF && imageData[1] == 0xD8) { + } else if (imageData.length >= 2 && + imageData[0] == 0xFF && + imageData[1] == 0xD8) { mimeType = 'image/jpeg'; } else { mimeType = 'image/jpeg'; } } - + final mimeBytes = utf8.encode(mimeType); const description = ''; final descBytes = utf8.encode(description); - - final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length + - 4 + 4 + 4 + 4 + 4 + imageData.length; - + + final blockSize = + 4 + + 4 + + mimeBytes.length + + 4 + + descBytes.length + + 4 + + 4 + + 4 + + 4 + + 4 + + imageData.length; + final buffer = ByteData(blockSize); var offset = 0; - + buffer.setUint32(offset, 3, Endian.big); offset += 4; - + buffer.setUint32(offset, mimeBytes.length, Endian.big); offset += 4; - + final blockBytes = Uint8List(blockSize); blockBytes.setRange(0, offset, buffer.buffer.asUint8List()); blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes); offset += mimeBytes.length; - + final tempBuffer = ByteData(4); tempBuffer.setUint32(0, descBytes.length, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - + blockBytes.setRange(offset, offset + descBytes.length, descBytes); offset += descBytes.length; - + tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - + tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - + tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - + tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - + tempBuffer.setUint32(0, imageData.length, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - + blockBytes.setRange(offset, offset + imageData.length, imageData); - + final base64String = base64Encode(blockBytes); - + return base64String; } catch (e) { _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); @@ -542,13 +584,15 @@ class FFmpegService { } } - static Map _convertToId3Tags(Map vorbisMetadata) { + static Map _convertToId3Tags( + Map vorbisMetadata, + ) { final id3Map = {}; - + for (final entry in vorbisMetadata.entries) { final key = entry.key.toUpperCase(); final value = entry.value; - + switch (key) { case 'TITLE': id3Map['title'] = value; @@ -585,7 +629,7 @@ class FFmpegService { id3Map[key.toLowerCase()] = value; } } - + return id3Map; } }