part of 'download_queue_provider.dart'; /// Result of [DownloadQueueNotifier._finalizeDecryption]. [failStage] is /// only meaningful to the inline single-item pipeline, which surfaces a /// distinct error message per stage; the native-worker pipeline uses one /// generic message and ignores it. class _DecryptOutcome { final String? path; final String? newFileName; final String? failStage; const _DecryptOutcome(this.path, {this.newFileName, this.failStage}); } class _QualityVariantFileOutcome { final String filePath; final String? fileName; final Map? metadata; const _QualityVariantFileOutcome({ required this.filePath, this.fileName, this.metadata, }); } /// AC-4 repair only applies to MP4 containers; decrypt can also emit raw /// FLAC, which the native MP4 box parser would reject as corrupt. bool _isMp4Container(String path) { final lower = path.toLowerCase(); return lower.endsWith('.m4a') || lower.endsWith('.mp4'); } extension _DownloadQueueFinalization on DownloadQueueNotifier { /// Builds the [DownloadHistoryItem] shared by the native-worker and inline /// completion paths. Fields whose source/derivation legitimately differs /// between the two callers (SAF location, probed vs. raw audio metadata, /// genre/label/copyright fallback chain) are left as parameters. DownloadHistoryItem _historyItemFromResult({ required DownloadItem item, required Track trackToDownload, required Map result, required String filePath, required String quality, required bool useSaf, String? downloadTreeUri, String? safRelativeDir, String? safFileName, int? bitDepth, int? sampleRate, int? bitrate, String? format, String? genre, String? label, String? copyright, }) { final backendTitle = result['title'] as String?; final backendArtist = result['artist'] as String?; final backendAlbum = result['album'] as String?; final backendYear = result['release_date'] as String?; final backendTrackNum = readPositiveInt(result['track_number']); final backendDiscNum = readPositiveInt(result['disc_number']); final backendTotalTracks = readPositiveInt(result['total_tracks']); final backendTotalDiscs = readPositiveInt(result['total_discs']); final backendISRC = result['isrc'] as String?; final backendComposer = result['composer'] as String?; final historyTotalTracks = _resolvePositiveMetadataInt( trackToDownload.totalTracks, backendTotalTracks, ); final historyTotalDiscs = _resolvePositiveMetadataInt( trackToDownload.totalDiscs, backendTotalDiscs, ); final historyTrackNumber = _resolveMetadataIndex( sourceValue: trackToDownload.trackNumber, backendValue: backendTrackNum, total: historyTotalTracks, ); final historyDiscNumber = _resolveMetadataIndex( sourceValue: trackToDownload.discNumber, backendValue: backendDiscNum, total: historyTotalDiscs, ); final historyTitle = _resolveMetadataText(trackToDownload.name, backendTitle) ?? item.track.name; final historyArtist = _resolveMetadataText(trackToDownload.artistName, backendArtist) ?? item.track.artistName; final historyAlbum = _resolveMetadataText(trackToDownload.albumName, backendAlbum) ?? item.track.albumName; final historyIsrc = _resolveMetadataText(trackToDownload.isrc, backendISRC); final historyReleaseDate = _resolveMetadataText( trackToDownload.releaseDate, backendYear, ); final historyComposer = _resolveMetadataText( trackToDownload.composer, backendComposer, ); return DownloadHistoryItem( id: item.id, trackName: historyTitle, artistName: historyArtist, albumName: historyAlbum, albumArtist: normalizeOptionalString(trackToDownload.albumArtist), coverUrl: normalizeCoverReference(trackToDownload.coverUrl), filePath: filePath, storageMode: useSaf ? 'saf' : 'app', downloadTreeUri: useSaf ? downloadTreeUri : null, safRelativeDir: useSaf ? safRelativeDir : null, safFileName: useSaf ? safFileName : null, safRepaired: false, service: result['service'] as String? ?? item.service, downloadedAt: DateTime.now(), isrc: historyIsrc, spotifyId: trackToDownload.id, trackNumber: historyTrackNumber, totalTracks: historyTotalTracks, discNumber: historyDiscNumber, totalDiscs: historyTotalDiscs, duration: trackToDownload.duration, releaseDate: historyReleaseDate, quality: quality, bitDepth: bitDepth, sampleRate: sampleRate, bitrate: bitrate, format: format, genre: genre, composer: historyComposer, label: label, copyright: copyright, ); } Future _copySafToTemp(String uri) async { try { return await PlatformBridge.copyContentUriToTemp(uri); } catch (e) { _log.w('Failed to copy SAF uri to temp: $e'); return null; } } Future _writeTempToSaf({ required String treeUri, required String relativeDir, required String fileName, required String mimeType, required String srcPath, }) async { try { return await PlatformBridge.createSafFileFromPath( treeUri: treeUri, relativeDir: relativeDir, fileName: fileName, mimeType: mimeType, srcPath: srcPath, ); } catch (e) { _log.w('Failed to write temp file to SAF: $e'); return null; } } Future<({String uri, String fileName})?> _writeTempToSafUnique({ required String treeUri, required String relativeDir, required String fileName, required String mimeType, required String srcPath, String preservedSuffix = '', }) async { try { final result = await PlatformBridge.createUniqueSafFileFromPath( treeUri: treeUri, relativeDir: relativeDir, fileName: fileName, mimeType: mimeType, srcPath: srcPath, preservedSuffix: preservedSuffix, ); 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); } catch (e) { _log.w('Failed to write unique temp file to SAF: $e'); return null; } } Future _writeLrcToSaf({ required String treeUri, required String relativeDir, required String baseName, required String lrcContent, }) async { try { if (lrcContent.isEmpty) return; final tempDir = await getTemporaryDirectory(); final tempPath = '${tempDir.path}/$baseName.lrc'; await File(tempPath).writeAsString(lrcContent); final lrcName = '$baseName.lrc'; final uri = await _writeTempToSaf( treeUri: treeUri, relativeDir: relativeDir, fileName: lrcName, mimeType: _mimeTypeForExt('.lrc'), srcPath: tempPath, ); if (uri != null) { _log.d('External LRC saved to SAF: $lrcName'); } else { _log.w('Failed to write external LRC to SAF'); } try { await File(tempPath).delete(); } catch (_) {} } catch (e) { _log.w('Failed to create external LRC in SAF: $e'); } } Future _deleteSafFile(String uri) async { try { await PlatformBridge.safDelete(uri); } catch (e) { _log.w('Failed to delete SAF file: $e'); } } /// Shared "SAF roundtrip" used by every finalize step that needs to /// transform a SAF file: copies [uri] to a local temp file, lets [op] /// transform it (returning the local path to publish plus the file name /// to publish it under, or null to abort), writes that file back into the /// SAF tree, deletes the original SAF file if its URI changed, and always /// cleans up the local temp file(s). Returns the new content:// URI, or /// null if the temp copy, [op], or the SAF write failed. Future _replaceSafFileVia({ required String uri, required String treeUri, required String relativeDir, required Future<(String path, String fileName)?> Function( String tempPath, void Function(String path) addCleanup, ) op, bool avoidOverwrite = false, String preservedSuffix = '', void Function(String fileName)? onPublishedFileName, }) async { final tempPath = await _copySafToTemp(uri); if (tempPath == null) return null; // Files op produces are registered here the moment they exist, so they // are cleaned up even if op throws before returning. final producedTemps = {}; String? outPath; try { final produced = await op(tempPath, producedTemps.add); if (produced == null) return null; outPath = produced.$1; final fileName = produced.$2; final dotIndex = fileName.lastIndexOf('.'); final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; String? newUri; if (avoidOverwrite) { final published = await _writeTempToSafUnique( treeUri: treeUri, relativeDir: relativeDir, fileName: fileName, mimeType: _mimeTypeForExt(ext), srcPath: outPath, preservedSuffix: preservedSuffix, ); newUri = published?.uri; if (published != null) { onPublishedFileName?.call(published.fileName); } } else { newUri = await _writeTempToSaf( treeUri: treeUri, relativeDir: relativeDir, fileName: fileName, mimeType: _mimeTypeForExt(ext), srcPath: outPath, ); if (newUri != null) { onPublishedFileName?.call(fileName); } } if (newUri == null) return null; if (newUri != uri) { await _deleteSafFile(uri); } return newUri; } finally { try { await File(tempPath).delete(); } catch (_) {} if (outPath != null) { producedTemps.add(outPath); } for (final path in producedTemps) { if (path == tempPath) continue; try { await File(path).delete(); } catch (_) {} } } } Future<_QualityVariantFileOutcome> _finalizeQualityVariantFilename({ required DownloadItem item, required Map result, required String filePath, required String storageMode, String? downloadTreeUri, String? safRelativeDir, String? fileName, }) async { if (!item.preserveQualityVariant || result['already_exists'] == true) { return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName); } Map? metadata; try { metadata = await PlatformBridge.readFileMetadata(filePath); if (metadata['error'] != null) metadata = null; } catch (e) { _log.d('Quality variant metadata probe failed for $filePath: $e'); } final bitDepth = readPositiveInt( metadata?['bit_depth'] ?? result['actual_bit_depth'], ); final sampleRate = readPositiveInt( metadata?['sample_rate'] ?? result['actual_sample_rate'], ); final detectedFormat = normalizeAudioFormatValue( metadata?['audio_codec']?.toString() ?? metadata?['codec']?.toString() ?? metadata?['format']?.toString(), ) ?? normalizeAudioFormatValue( result['audio_codec']?.toString() ?? result['format']?.toString(), ) ?? normalizeAudioFormatValue( audioFormatForPath(filePath, fileName: fileName), ); final bitrateKbps = readPositiveBitrateKbps( metadata?['bitrate'] ?? metadata?['bit_rate'] ?? result['bitrate'] ?? result['actual_bitrate'], ); final qualityLabel = buildQualityVariantFilenameLabel( detectedFormat: detectedFormat, bitDepth: bitDepth, sampleRate: sampleRate, bitrateKbps: bitrateKbps, measuredQuality: result['_native_actual_quality']?.toString() ?? result['quality']?.toString(), ); if (qualityLabel == null) { _log.w( 'Keeping collision-safe temporary quality label because the final ' 'audio specification could not be measured: $filePath', ); return _QualityVariantFileOutcome( filePath: filePath, fileName: fileName, metadata: metadata, ); } if (bitDepth != null) result['actual_bit_depth'] = bitDepth; if (sampleRate != null) result['actual_sample_rate'] = sampleRate; if (detectedFormat != null) result['audio_codec'] = detectedFormat; if (bitrateKbps != null && isLossyAudioFormat(detectedFormat)) { result['bitrate'] = bitrateKbps; } final stagingLabel = qualityVariantStagingLabel(item.id); final localPathSegments = File(filePath).uri.pathSegments; final currentFileName = storageMode == 'saf' && isContentUri(filePath) ? (fileName ?? result['file_name']?.toString() ?? '') : (localPathSegments.isEmpty ? '' : localPathSegments.last); final preferredFileName = applyQualityVariantFilenameLabel( fileName: currentFileName, stagingLabel: stagingLabel, qualityLabel: qualityLabel, ); if (preferredFileName == currentFileName) { return _QualityVariantFileOutcome( filePath: filePath, fileName: fileName, metadata: metadata, ); } if (storageMode == 'saf' && isContentUri(filePath)) { if (downloadTreeUri == null || downloadTreeUri.isEmpty) { return _QualityVariantFileOutcome( filePath: filePath, fileName: fileName, metadata: metadata, ); } String? publishedFileName; final renamedUri = await _replaceSafFileVia( uri: filePath, treeUri: downloadTreeUri, relativeDir: safRelativeDir ?? '', avoidOverwrite: true, preservedSuffix: qualityLabel, onPublishedFileName: (name) => publishedFileName = name, op: (tempPath, addCleanup) async => (tempPath, preferredFileName), ); if (renamedUri == null) { return _QualityVariantFileOutcome( filePath: filePath, fileName: fileName, metadata: metadata, ); } final finalName = publishedFileName ?? preferredFileName; result['file_path'] = renamedUri; result['file_name'] = finalName; return _QualityVariantFileOutcome( filePath: renamedUri, fileName: finalName, metadata: metadata, ); } final source = File(filePath); final parent = source.parent; var target = File( '${parent.path}${Platform.pathSeparator}$preferredFileName', ); var counter = 2; while (await target.exists() && target.path != source.path) { final dotIndex = preferredFileName.lastIndexOf('.'); final hasExtension = dotIndex > 0; final stem = hasExtension ? preferredFileName.substring(0, dotIndex) : preferredFileName; final extension = hasExtension ? preferredFileName.substring(dotIndex) : ''; target = File( '${parent.path}${Platform.pathSeparator}$stem ($counter)$extension', ); counter++; } try { final renamed = await source.rename(target.path); result['file_path'] = renamed.path; final renamedSegments = renamed.uri.pathSegments; result['file_name'] = renamedSegments.isEmpty ? null : renamedSegments.last; return _QualityVariantFileOutcome( filePath: renamed.path, fileName: result['file_name'] as String?, metadata: metadata, ); } catch (e) { _log.w('Failed to apply measured quality filename: $e'); return _QualityVariantFileOutcome( filePath: filePath, fileName: fileName, metadata: metadata, ); } } /// Shared decrypt finalize used by both the inline single-item pipeline /// and the native-worker pipeline. Divergences captured as parameters: /// [repairAc4] (inline repairs AC-4 containers using the still-encrypted /// source; native-worker does not) and [onStart] (inline logs its own /// "detected" message; native-worker logs a differently worded one). Future<_DecryptOutcome> _finalizeDecryption({ required Map result, required String filePath, required String storageMode, String? downloadTreeUri, required String safRelativeDir, required String baseName, required String extFallback, required bool repairAc4, void Function(String strategy)? onStart, }) async { if (result['already_exists'] == true) { return _DecryptOutcome(filePath); } final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); if (descriptor == null) { return _DecryptOutcome(filePath); } onStart?.call(descriptor.normalizedStrategy); if (storageMode == 'saf' && isContentUri(filePath)) { if (downloadTreeUri == null || downloadTreeUri.isEmpty) { return const _DecryptOutcome( null, failStage: DownloadQueueNotifier._decryptStageSafAccess, ); } String? failStage; var opStarted = false; String? producedFileName; final newUri = await _replaceSafFileVia( uri: filePath, treeUri: downloadTreeUri, relativeDir: safRelativeDir, op: (tempPath, addCleanup) async { opStarted = true; final decryptedTempPath = await FFmpegService.decryptWithDescriptor( inputPath: tempPath, descriptor: descriptor, deleteOriginal: false, ); if (decryptedTempPath == null) { failStage = DownloadQueueNotifier._decryptStageDecrypt; return null; } addCleanup(decryptedTempPath); if (repairAc4 && _isMp4Container(decryptedTempPath)) { try { await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath); } catch (e) { _log.w('AC-4 container repair skipped: $e'); } } final dotIndex = decryptedTempPath.lastIndexOf('.'); final decryptedExt = dotIndex >= 0 ? decryptedTempPath.substring(dotIndex).toLowerCase() : extFallback; const allowedExt = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; final finalExt = allowedExt.contains(decryptedExt) ? decryptedExt : extFallback; final newFileName = '$baseName$finalExt'; producedFileName = newFileName; return (decryptedTempPath, newFileName); }, ); if (newUri == null) { return _DecryptOutcome( null, failStage: failStage ?? (opStarted ? DownloadQueueNotifier._decryptStageSafWrite : DownloadQueueNotifier._decryptStageSafAccess), ); } return _DecryptOutcome(newUri, newFileName: producedFileName); } if (repairAc4) { final decryptedPath = await FFmpegService.decryptWithDescriptor( inputPath: filePath, descriptor: descriptor, deleteOriginal: false, ); if (decryptedPath == null) { try { await deleteFile(filePath); } catch (_) {} return const _DecryptOutcome( null, failStage: DownloadQueueNotifier._decryptStageDecrypt, ); } if (_isMp4Container(decryptedPath)) { try { await PlatformBridge.ensureAC4Config(decryptedPath, filePath); } catch (e) { _log.w('AC-4 container repair skipped: $e'); } } try { await deleteFile(filePath); } catch (_) {} return _DecryptOutcome(decryptedPath); } final decryptedPath = await FFmpegService.decryptWithDescriptor( inputPath: filePath, descriptor: descriptor, deleteOriginal: true, ); return _DecryptOutcome( decryptedPath, failStage: decryptedPath == null ? DownloadQueueNotifier._decryptStageDecrypt : null, ); } Future _finalizeNativeWorkerHighConversion({ required _NativeWorkerRequestContext context, required Map result, required AppSettings settings, required Track track, required String filePath, }) async { if (context.quality != 'HIGH') { return filePath; } final lowerPath = filePath.toLowerCase(); final resultFileName = (result['file_name'] as String?)?.toLowerCase(); final looksLikeM4a = lowerPath.endsWith('.m4a') || lowerPath.endsWith('.mp4') || (resultFileName != null && (resultFileName.endsWith('.m4a') || resultFileName.endsWith('.mp4'))); if (!looksLikeM4a) { return filePath; } final tidalHighFormat = settings.tidalHighFormat; final format = lossyFormatForSetting(tidalHighFormat); final newExt = lossyExtensionForFormat(format); final displayFormat = displayFormatForLossyFormat(format); final bitrateDisplay = tidalHighFormat.contains('_') ? '${tidalHighFormat.split('_').last}kbps' : '320kbps'; Future embedConvertedMetadata(String convertedPath) async { if (!settings.embedMetadata) return; await _embedMetadataToFile( convertedPath, track, format: metadataFormatForLossyFormat(format), genre: result['genre'] as String?, label: result['label'] as String?, copyright: result['copyright'] as String?, downloadService: context.item.service, ); } if (context.storageMode == 'saf' && isContentUri(filePath)) { final treeUri = context.downloadTreeUri; if (treeUri == null || treeUri.isEmpty) { return null; } final rawFileName = (result['file_name'] as String?) ?? context.safFileName ?? 'track'; final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); final newFileName = '$baseName$newExt'; final newUri = await _replaceSafFileVia( uri: filePath, treeUri: treeUri, relativeDir: context.safRelativeDir ?? '', op: (tempPath, addCleanup) async { final convertedPath = await FFmpegService.convertM4aToLossy( tempPath, format: format, bitrate: tidalHighFormat, deleteOriginal: false, ); if (convertedPath == null) return null; addCleanup(convertedPath); await embedConvertedMetadata(convertedPath); return (convertedPath, newFileName); }, ); if (newUri == null) { return null; } result['file_name'] = newFileName; result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; return newUri; } final convertedPath = await FFmpegService.convertM4aToLossy( filePath, format: format, bitrate: tidalHighFormat, deleteOriginal: true, ); if (convertedPath == null) { return null; } await embedConvertedMetadata(convertedPath); result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; return convertedPath; } Future _finalizeNativeWorkerContainerConversion({ required _NativeWorkerRequestContext context, required Map result, required AppSettings settings, required Track track, required String filePath, }) async { if (context.quality == 'HIGH' || context.outputExt != '.flac') { return filePath; } final resultAudioFormat = normalizeAudioFormatValue( result['audio_codec']?.toString() ?? result['actual_audio_codec']?.toString(), ); if (isLossyAudioFormat(resultAudioFormat)) { _log.d( 'Native-worker output is $resultAudioFormat; preserving native container.', ); return filePath; } final requiresContainerConversion = result['requires_container_conversion'] == true || result['requiresContainerConversion'] == true; final resultOutputExt = _downloadResultOutputExt( result, filePath: filePath, ); final lowerPath = filePath.toLowerCase(); final resultFileName = (result['file_name'] as String?)?.toLowerCase(); final mayNeedContainerConversion = requiresContainerConversion || lowerPath.endsWith('.m4a') || lowerPath.endsWith('.mp4') || resultOutputExt == '.m4a' || resultOutputExt == '.mp4' || isContentUri(filePath); if (!mayNeedContainerConversion) { return filePath; } final requestedDecryptionExt = DownloadDecryptionDescriptor.fromDownloadResult( result, )?.normalizedOutputExtension; if (!requiresContainerConversion && requestedDecryptionExt != null && requestedDecryptionExt != '.flac') { _log.d( 'Native-worker decrypted output requested $requestedDecryptionExt; preserving native container.', ); return filePath; } final looksLikeM4a = lowerPath.endsWith('.m4a') || lowerPath.endsWith('.mp4') || resultOutputExt == '.m4a' || resultOutputExt == '.mp4' || (resultFileName != null && (resultFileName.endsWith('.m4a') || resultFileName.endsWith('.mp4'))); if (!requiresContainerConversion && !looksLikeM4a && !isContentUri(filePath)) { return filePath; } Future embedFlacMetadata(String flacPath) async { if (!settings.embedMetadata) return; await _embedMetadataToFile( flacPath, track, format: 'flac', genre: result['genre'] as String?, label: result['label'] as String?, copyright: result['copyright'] as String?, downloadService: context.item.service, writeExternalLrc: context.storageMode != 'saf', ); } if (context.storageMode == 'saf' && isContentUri(filePath)) { final treeUri = context.downloadTreeUri; if (treeUri == null || treeUri.isEmpty) { return null; } var preserve = false; String? producedFileName; final newUri = await _replaceSafFileVia( uri: filePath, treeUri: treeUri, relativeDir: context.safRelativeDir ?? '', op: (tempPath, addCleanup) async { final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); if (!FFmpegService.isLosslessAudioCodec(codec)) { _log.d( 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' 'no FLAC container conversion needed.', ); preserve = true; return null; } if (isAlreadyNativeFlac) { _log.d( 'Native FLAC payload detected in temporary container; publishing ' 'as FLAC and embedding metadata.', ); await embedFlacMetadata(tempPath); final rawFileName = (result['file_name'] as String?) ?? context.safFileName ?? 'track'; final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); final newFileName = '$baseName.flac'; producedFileName = newFileName; return (tempPath, newFileName); } final flacPath = await FFmpegService.convertM4aToFlac(tempPath); if (flacPath == null) { return null; } addCleanup(flacPath); await embedFlacMetadata(flacPath); final rawFileName = (result['file_name'] as String?) ?? context.safFileName ?? 'track'; final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); final newFileName = '$baseName.flac'; producedFileName = newFileName; return (flacPath, newFileName); }, ); if (preserve) { return filePath; } if (newUri == null) { return null; } result['file_name'] = producedFileName; return newUri; } final codec = await FFmpegService.probePrimaryAudioCodec(filePath); final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath); if (!FFmpegService.isLosslessAudioCodec(codec)) { _log.d( 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' 'no FLAC container conversion needed.', ); return filePath; } if (isAlreadyNativeFlac) { var flacPath = filePath; if (!filePath.toLowerCase().endsWith('.flac')) { final renamedPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.flac'); final targetPath = renamedPath == filePath ? '$filePath.flac' : renamedPath; await File(filePath).rename(targetPath); flacPath = targetPath; } await embedFlacMetadata(flacPath); return flacPath; } final flacPath = await FFmpegService.convertM4aToFlac(filePath); if (flacPath == null) { return null; } await embedFlacMetadata(flacPath); return flacPath; } /// Shared external-LRC finalize used by both the inline single-item /// pipeline (SAF only; the local-file case is already handled during /// metadata embedding) and the native-worker pipeline (both storage /// modes). [resolveBaseName] and [onFetchError] are each caller's own /// base-name fallback chain and fetch-failure log line, evaluated lazily /// to match the original call sites exactly. Future _saveExternalLrc({ required Map result, required AppSettings settings, required ExtensionState extensionState, required Track track, required String service, required String filePath, required String storageMode, String? downloadTreeUri, required String safRelativeDir, required Future Function() resolveBaseName, required void Function(Object e) onFetchError, }) async { final lyricsMode = settings.lyricsMode; final shouldSaveExternalLrc = settings.embedMetadata && settings.embedLyrics && !_shouldSkipLyrics(extensionState, track.source, service) && (lyricsMode == 'external' || lyricsMode == 'both'); if (!shouldSaveExternalLrc) { return; } String? lrcContent = result['lyrics_lrc'] as String?; if (lrcContent == null || lrcContent.isEmpty) { try { lrcContent = await PlatformBridge.getLyricsLRC( track.id, track.name, track.artistName, durationMs: track.duration * 1000, ); } catch (e) { onFetchError(e); } } if (lrcContent == null || lrcContent.isEmpty) { return; } if (storageMode == 'saf' && isContentUri(filePath)) { if (downloadTreeUri == null || downloadTreeUri.isEmpty) { return; } final baseName = await resolveBaseName(); await _writeLrcToSaf( treeUri: downloadTreeUri, relativeDir: safRelativeDir, baseName: baseName, lrcContent: lrcContent, ); return; } try { final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath; await File(safeLrcPath).writeAsString(lrcContent); _log.d('Native-worker external LRC saved: $safeLrcPath'); } catch (e) { _log.w('Failed to save native-worker external LRC: $e'); } } }