From e149ee53581b9ea68f92a391ac2eac3c38ea9bde Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 26 Aug 2026 18:37:15 +0700 Subject: [PATCH] fix(storage): publish finalized SAF output once --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 25 +++ .../download_queue_provider_finalization.dart | 32 +++- .../download_queue_provider_single_item.dart | 150 +++++++++++++++++- lib/services/platform_bridge.dart | 16 ++ 4 files changed, 220 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c9544d35..e97e119f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1136,6 +1136,31 @@ class MainActivity: FlutterFragmentActivity() { } result.success(createdUri) } + "safCreateIfAbsentFromPath" -> { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val response = withContext(Dispatchers.IO) { + if (treeUriStr.isBlank() || fileName.isBlank()) return@withContext null + SafDownloadHandler.writeFileToSafIfAbsent( + context = this@MainActivity, + treeUriStr = treeUriStr, + relativeDir = relativeDir, + fileName = fileName, + mimeType = mimeType, + srcPath = srcPath, + )?.let { writeResult -> + JSONObject() + .put("uri", writeResult.uri) + .put("file_name", writeResult.fileName) + .put("already_exists", writeResult.alreadyExists) + .toString() + } + } + result.success(response) + } "safCreateUniqueFromPath" -> { val treeUriStr = call.argument("tree_uri") ?: "" val relativeDir = call.argument("relative_dir") ?: "" diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index 045e5222..f0d63986 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -376,6 +376,36 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { } } + Future<({String uri, String fileName, bool alreadyExists})?> + _writeTempToSafIfAbsent({ + required String treeUri, + required String relativeDir, + required String fileName, + required String mimeType, + required String srcPath, + }) async { + try { + final result = await PlatformBridge.createSafFileIfAbsentFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: mimeType, + srcPath: srcPath, + ); + final uri = (result['uri'] as String? ?? '').trim(); + final publishedName = (result['file_name'] as String? ?? '').trim(); + if (uri.isEmpty || publishedName.isEmpty) return null; + return ( + uri: uri, + fileName: publishedName, + alreadyExists: result['already_exists'] == true, + ); + } catch (e) { + _log.w('Failed to publish deferred SAF file: $e'); + return null; + } + } + Future<({String uri, String fileName})?> _writeTempToSafUnique({ required String treeUri, required String relativeDir, @@ -644,7 +674,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { final stagingLabel = qualityVariantStagingLabel(item.id); final localPathSegments = File(filePath).uri.pathSegments; - final currentFileName = storageMode == 'saf' && isContentUri(filePath) + final currentFileName = storageMode == 'saf' ? (fileName ?? result['file_name']?.toString() ?? '') : (localPathSegments.isEmpty ? '' : localPathSegments.last); final variantFileName = applyQualityVariantFilenameLabel( diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 508ef3e2..125c7003 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -580,6 +580,7 @@ class _DownloadRun { genre: genre, label: label, copyright: copyright, + stageSafForDeferredPublish: useSaf, qualityVariantCollisionOnly: qualityVariantCollisionOnly, ); @@ -726,7 +727,14 @@ class _DownloadRun { return false; } - await _recoverSafUriIfNeeded(); + final deferredSafPublish = + effectiveSafMode && + result['saf_deferred_publish'] == true && + filePath != null && + !isContentUri(filePath!); + if (!deferredSafPublish) { + await _recoverSafUriIfNeeded(); + } final hookInput = filePath; if (hookInput != null) { @@ -788,6 +796,10 @@ class _DownloadRun { ); } + if (deferredSafPublish && !await _publishDeferredSafOutputOnce()) { + throw StateError('Failed to publish deferred SAF output'); + } + final lrcTarget = filePath; if (effectiveSafMode && lrcTarget != null && isContentUri(lrcTarget)) { await n._saveExternalLrc( @@ -950,14 +962,148 @@ class _DownloadRun { await _embedSafNonM4a(path); } else if (metadataEmbeddingEnabled && !isContentUriPath && - !effectiveSafMode && isFlacFile && !wasExisting && decryptionDescriptor != null) { await _embedLocalFlacAfterDecrypt(path); + } else if (metadataEmbeddingEnabled && + !isContentUriPath && + effectiveSafMode && + result['saf_deferred_publish'] == true && + !isFlacFile && + !isM4aFile && + !wasExisting) { + final isOpus = + path.toLowerCase().endsWith('.opus') || + path.toLowerCase().endsWith('.ogg') || + resultOutputExt == '.opus' || + resultOutputExt == '.ogg'; + await _embedFinalMetadata( + path, + format: isOpus ? 'opus' : 'mp3', + writeExternalLrc: false, + ); } } + Future _publishDeferredSafOutputOnce() async { + final localPath = filePath; + if (localPath == null || isContentUri(localPath)) return true; + final localFile = File(localPath); + if (!await localFile.exists() || await localFile.length() <= 0) { + return false; + } + + var finalName = + normalizeOptionalString(finalSafFileName) ?? + normalizeOptionalString(result['file_name']?.toString()) ?? + normalizeOptionalString(safFileName) ?? + (localFile.uri.pathSegments.isEmpty + ? 'track$safOutputExt' + : localFile.uri.pathSegments.last); + final localExt = n._downloadResultOutputExt(result, filePath: localPath); + if (localExt != null && localExt.isNotEmpty) { + finalName = finalName.replaceFirst(RegExp(r'\.[^.]+$'), localExt); + if (!finalName.toLowerCase().endsWith(localExt.toLowerCase())) { + finalName = '$finalName$localExt'; + } + } + + final measured = probedFinalMetadata; + final qualityLabel = buildQualityVariantFilenameLabel( + detectedFormat: + normalizeAudioFormatValue( + measured?['audio_codec']?.toString() ?? + measured?['format']?.toString(), + ) ?? + normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ), + bitDepth: readPositiveInt( + measured?['bit_depth'] ?? result['actual_bit_depth'], + ), + sampleRate: readPositiveInt( + measured?['sample_rate'] ?? result['actual_sample_rate'], + ), + bitrateKbps: readPositiveBitrateKbps( + measured?['bitrate'] ?? + measured?['bit_rate'] ?? + result['bitrate'] ?? + result['actual_bitrate'], + ), + measuredQuality: actualQuality, + ); + + String? publishedUri; + String? publishedName; + var alreadyExists = false; + if (item.preserveQualityVariant && qualityVariantCollisionOnly) { + final logicalName = safFileName ?? finalName; + final stagingLabel = qualityVariantStagingLabel(item.id); + final cleanName = removeQualityVariantStagingLabel( + fileName: logicalName, + stagingLabel: stagingLabel, + ); + final variantName = qualityLabel == null + ? finalName + : applyQualityVariantFilenameLabel( + fileName: logicalName, + stagingLabel: stagingLabel, + qualityLabel: qualityLabel, + ); + final published = await n._writeTempToSafCollisionAware( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + cleanFileName: cleanName, + variantFileName: variantName, + mimeType: n._mimeTypeForExt(localExt ?? safOutputExt), + srcPath: localPath, + preservedSuffix: qualityLabel ?? '', + ); + publishedUri = published?.uri; + publishedName = published?.fileName; + } else if (item.preserveQualityVariant) { + final published = await n._writeTempToSafUnique( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: finalName, + mimeType: n._mimeTypeForExt(localExt ?? safOutputExt), + srcPath: localPath, + preservedSuffix: qualityLabel ?? '', + ); + publishedUri = published?.uri; + publishedName = published?.fileName; + } else { + final published = await n._writeTempToSafIfAbsent( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: finalName, + mimeType: n._mimeTypeForExt(localExt ?? safOutputExt), + srcPath: localPath, + ); + publishedUri = published?.uri; + publishedName = published?.fileName; + alreadyExists = published?.alreadyExists == true; + } + if (publishedUri == null || publishedName == null) return false; + + try { + await localFile.delete(); + } catch (_) {} + filePath = publishedUri; + finalSafFileName = publishedName; + wasExisting = alreadyExists; + result['file_path'] = publishedUri; + result['file_name'] = publishedName; + result['saf_deferred_published'] = true; + if (alreadyExists) { + result['already_exists'] = true; + result['message'] = 'File already exists'; + } + _log.i('Published finalized SAF output once: $publishedName'); + return true; + } + Future _convertSafM4aToLossy(String currentFilePath) async { final tidalHighFormat = settings.autoConvertDownloads ? autoConvertLossySetting( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 2ab3ea7c..183688a0 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -801,6 +801,22 @@ class PlatformBridge { return result as String?; } + static Future> createSafFileIfAbsentFromPath({ + required String treeUri, + required String relativeDir, + required String fileName, + required String mimeType, + required String srcPath, + }) { + return _invokeMap('safCreateIfAbsentFromPath', { + 'tree_uri': treeUri, + 'relative_dir': relativeDir, + 'file_name': fileName, + 'mime_type': mimeType, + 'src_path': srcPath, + }); + } + static Future> createUniqueSafFileFromPath({ required String treeUri, required String relativeDir,