diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index d01d68ad..75408f28 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -543,9 +543,20 @@ object NativeDownloadFinalizer { try { val codec = probePrimaryAudioCodec(localInput, shouldCancel) val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput) - if (!isLosslessAudioCodec(codec) || isAlreadyNativeFlac) { - val suffix = if (isAlreadyNativeFlac) " (native FLAC)" else "" - Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}$suffix") + if (!isLosslessAudioCodec(codec)) { + Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}") + return + } + if (isAlreadyNativeFlac) { + Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata") + val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) { + localInput + } else { + File(localInput).copyTo(File(output), overwrite = true).absolutePath + } + embedBasicMetadata(context, nativeFlacOutput, input, "flac") + replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true) + adoptedOutput = true return } val result = runFFmpeg( diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 80649d09..2b38818a 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -367,8 +367,8 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio jar, _ := newSimpleCookieJar() runtime.cookieJar = jar } - runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second)) - runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout) + runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true) + runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false) runtime.RegisterAPIs(vm) runtime.RegisterGoBackendAPIs(vm) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 9cf544df..f29d3528 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -140,8 +140,8 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime { storageFlushDelay: defaultStorageFlushDelay, } - runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second)) - runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout) + runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true) + runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false) return runtime } @@ -247,13 +247,18 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re return req.WithContext(initDownloadCancel(itemID)) } -func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { +func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client { // Extension sandbox enforces HTTPS-only domains. Do not apply global // allow_http scheme downgrade here, because some extension APIs (e.g. // spotify-web) will redirect http -> https and can end up in 301 loops. - // We still reuse sharedTransport so insecure TLS compatibility mode remains effective. + // API calls can use response compression for faster metadata/search loads, + // while media downloads keep identity transfer semantics for progress/streaming. + transport := sharedTransport + if compressResponses { + transport = extensionAPITransport + } client := &http.Client{ - Transport: sharedTransport, + Transport: transport, Timeout: timeout, Jar: jar, } diff --git a/go_backend/httputil.go b/go_backend/httputil.go index e48dc7d7..17b22538 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -79,6 +79,24 @@ var sharedTransport = &http.Transport{ DisableCompression: true, } +var extensionAPITransport = &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + DisableKeepAlives: false, + ForceAttemptHTTP2: true, + WriteBufferSize: 64 * 1024, + ReadBufferSize: 64 * 1024, + DisableCompression: false, +} + var metadataTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -131,6 +149,7 @@ func GetDownloadClient() *http.Client { func CloseIdleConnections() { sharedTransport.CloseIdleConnections() + extensionAPITransport.CloseIdleConnections() metadataTransport.CloseIdleConnections() } @@ -143,6 +162,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) { networkCompatibilityMu.Unlock() applyTLSCompatibility(sharedTransport, insecureTLS) + applyTLSCompatibility(extensionAPITransport, insecureTLS) applyTLSCompatibility(metadataTransport, insecureTLS) CloseIdleConnections() diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 52c48080..ed49b122 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -6320,14 +6320,41 @@ class DownloadQueueNotifier extends Notifier { final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); - if (!FFmpegService.isLosslessAudioCodec(codec) || isAlreadyNativeFlac) { + if (!FFmpegService.isLosslessAudioCodec(codec)) { _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}' - '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' 'no FLAC container conversion needed.', ); return filePath; } + 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'; + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + fileName: newFileName, + mimeType: _mimeTypeForExt('.flac'), + srcPath: tempPath, + ); + if (newUri == null) { + return null; + } + if (newUri != filePath) { + await _deleteSafFile(filePath); + } + result['file_name'] = newFileName; + return newUri; + } flacPath = await FFmpegService.convertM4aToFlac(tempPath); if (flacPath == null) { return null; @@ -6367,14 +6394,26 @@ class DownloadQueueNotifier extends Notifier { final codec = await FFmpegService.probePrimaryAudioCodec(filePath); final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath); - if (!FFmpegService.isLosslessAudioCodec(codec) || isAlreadyNativeFlac) { + if (!FFmpegService.isLosslessAudioCodec(codec)) { _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}' - '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + '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; @@ -7721,11 +7760,9 @@ class DownloadQueueNotifier extends Notifier { final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); - if (!FFmpegService.isLosslessAudioCodec(codec) || - isAlreadyNativeFlac) { + if (!FFmpegService.isLosslessAudioCodec(codec)) { _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}' - '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' 'no FLAC container conversion needed.', ); final preserveExt = resultOutputExt == '.mp4' @@ -7747,6 +7784,49 @@ class DownloadQueueNotifier extends Notifier { filePath = newUri; finalSafFileName = newFileName; } + } else if (isAlreadyNativeFlac) { + _log.d( + 'Native FLAC payload detected in SAF temp file; ' + 'publishing as FLAC and embedding metadata.', + ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + await _embedMetadataToFile( + tempPath, + finalTrack, + format: 'flac', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + writeExternalLrc: false, + ); + + 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; + } else { + _log.w('Failed to write native FLAC to SAF'); + } } else { updateItemStatus( item.id, @@ -7960,13 +8040,49 @@ class DownloadQueueNotifier extends Notifier { final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(currentFilePath); - if (!FFmpegService.isLosslessAudioCodec(codec) || - isAlreadyNativeFlac) { + if (!FFmpegService.isLosslessAudioCodec(codec)) { _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}' - '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' 'no FLAC container conversion needed.', ); + } else if (isAlreadyNativeFlac) { + _log.d( + 'Native FLAC payload detected; ensuring .flac ' + 'extension and embedding metadata.', + ); + var flacPath = currentFilePath; + if (!currentFilePath.toLowerCase().endsWith('.flac')) { + final renamedPath = currentFilePath.replaceAll( + RegExp(r'\.[^.]+$'), + '.flac', + ); + final targetPath = renamedPath == currentFilePath + ? '$currentFilePath.flac' + : renamedPath; + await File(currentFilePath).rename(targetPath); + flacPath = targetPath; + filePath = targetPath; + } + + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + await _embedMetadataToFile( + flacPath, + finalTrack, + format: 'flac', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + ); } else { updateItemStatus( item.id, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index c93f8d3a..27673b9e 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -21,6 +21,15 @@ class _BridgeCacheEntry { bool get isExpired => DateTime.now().isAfter(expiresAt); } +class _BridgeListCacheEntry { + final List> value; + final DateTime expiresAt; + + const _BridgeListCacheEntry({required this.value, required this.expiresAt}); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + class _BridgeInFlight { final String requestId; final String scopeKey; @@ -39,6 +48,8 @@ class PlatformBridge { static const _backgroundJsonDecodeThresholdBytes = 128 * 1024; static const _metadataCacheTtl = Duration(minutes: 20); static const _availabilityCacheTtl = Duration(minutes: 15); + static const _urlHandleCacheTtl = Duration(minutes: 5); + static const _customSearchCacheTtl = Duration(minutes: 2); static const _bridgeCacheMaxEntries = 256; static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1'; static const _availabilityPersistentCacheKey = @@ -51,9 +62,13 @@ class PlatformBridge { ); static final Map _metadataCache = {}; static final Map _availabilityCache = {}; + static final Map _urlHandleCache = {}; + static final Map _customSearchCache = {}; static final Map>> _metadataInFlight = {}; static final Map>> _availabilityInFlight = {}; + static final Map?>> _urlHandleInFlight = + {}; static final Map>>> _customSearchInFlight = {}; static final Map?>> @@ -329,8 +344,11 @@ class PlatformBridge { _persistentLookupCacheLoadFuture = null; _metadataCache.clear(); _availabilityCache.clear(); + _urlHandleCache.clear(); + _customSearchCache.clear(); _metadataInFlight.clear(); _availabilityInFlight.clear(); + _urlHandleInFlight.clear(); for (final inFlight in _customSearchInFlight.values) { _cancelExtensionRequestUnawaited(inFlight.requestId); } @@ -1388,7 +1406,11 @@ class PlatformBridge { _cancelCustomSearchInFlightForScope(scopeKey, exceptKey: cacheKey); } + final cached = _getCachedMapList(_customSearchCache, cacheKey); + if (cached != null) return cached; + final requestId = _nextExtensionRequestId('customSearch', extensionId); + final generation = _lookupCacheGeneration; final future = (() async { final result = await _channel.invokeMethod('customSearchWithExtension', { 'extension_id': extensionId, @@ -1396,7 +1418,16 @@ class PlatformBridge { 'options': optionsJson, 'request_id': requestId, }); - return _decodeMapListResult(result, 'customSearchWithExtension'); + final decoded = _decodeMapListResult(result, 'customSearchWithExtension'); + if (generation == _lookupCacheGeneration) { + _putMemoryCachedMapList( + _customSearchCache, + cacheKey, + decoded, + _customSearchCacheTtl, + ); + } + return decoded; })(); final entry = _BridgeInFlight>>( @@ -1422,14 +1453,115 @@ class PlatformBridge { static Future?> handleURLWithExtension( String url, ) async { + final cacheKey = url.trim(); + if (cacheKey.isEmpty) return null; + + final cached = _getCachedMap(_urlHandleCache, cacheKey); + if (cached != null) return cached; + + final inFlight = _urlHandleInFlight[cacheKey]; + if (inFlight != null) return _copyNullableStringMap(await inFlight); + + final generation = _lookupCacheGeneration; + final future = (() async { + try { + final result = await _channel.invokeMethod('handleURLWithExtension', { + 'url': url, + }); + final decoded = _decodeNullableMapResult( + result, + 'handleURLWithExtension', + ); + if (generation == _lookupCacheGeneration && + decoded != null && + _isCacheableURLHandleResult(decoded)) { + _putMemoryCachedMap( + _urlHandleCache, + cacheKey, + decoded, + _urlHandleCacheTtl, + ); + } + return decoded; + } catch (e) { + return null; + } + })(); + + _urlHandleInFlight[cacheKey] = future; try { - final result = await _channel.invokeMethod('handleURLWithExtension', { - 'url': url, - }); - return _decodeNullableMapResult(result, 'handleURLWithExtension'); - } catch (e) { + return _copyNullableStringMap(await future); + } finally { + if (identical(_urlHandleInFlight[cacheKey], future)) { + _urlHandleInFlight.remove(cacheKey); + } + } + } + + static bool _isCacheableURLHandleResult(Map result) { + final type = result['type']?.toString(); + if (type == null || type.isEmpty) return false; + if (type == 'track') { + final track = result['track']; + if (track is! Map) return false; + final name = track['name']?.toString().trim() ?? ''; + return name.isNotEmpty; + } + return type == 'album' || type == 'playlist' || type == 'artist'; + } + + static void _putMemoryCachedMap( + Map cache, + String key, + Map value, + Duration ttl, + ) { + _pruneExpiredBridgeCache(cache); + while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) { + cache.remove(cache.keys.first); + } + cache[key] = _BridgeCacheEntry( + value: _copyStringMap(value), + expiresAt: DateTime.now().add(ttl), + ); + } + + static List>? _getCachedMapList( + Map cache, + String key, + ) { + _pruneExpiredBridgeListCache(cache); + final entry = cache[key]; + if (entry == null) return null; + if (entry.isExpired) { + cache.remove(key); return null; } + return _copyMapList(entry.value); + } + + static void _putMemoryCachedMapList( + Map cache, + String key, + List> value, + Duration ttl, + ) { + _pruneExpiredBridgeListCache(cache); + while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) { + cache.remove(cache.keys.first); + } + cache[key] = _BridgeListCacheEntry( + value: _copyMapList(value), + expiresAt: DateTime.now().add(ttl), + ); + } + + static void _pruneExpiredBridgeListCache( + Map cache, + ) { + if (cache.isEmpty) return; + final now = DateTime.now(); + cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt)); } static Future findURLHandler(String url) async { diff --git a/pubspec.lock b/pubspec.lock index 732f2d13..42565ec4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1170,14 +1170,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.6+1" - sqflite_common_ffi: - dependency: "direct main" - description: - name: sqflite_common_ffi - sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20 - url: "https://pub.dev" - source: hosted - version: "2.4.0+3" sqflite_darwin: dependency: transitive description: @@ -1194,14 +1186,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91 - url: "https://pub.dev" - source: hosted - version: "3.2.0" stack_trace: dependency: transitive description: