fix: native FLAC handling and extension API optimizations

Native FLAC handling:
- Properly detect and publish native FLAC payloads inside MP4 containers
- Rename to .flac extension and embed metadata instead of skipping
- Fix all code paths: SAF, non-SAF, and native worker finalizer

Extension API optimizations:
- Enable response compression for API/search calls (faster metadata loads)
- Keep downloads uncompressed for accurate progress/streaming
- Add separate extensionAPITransport with compression enabled

Platform bridge caching:
- Cache handleURLWithExtension results (5 min TTL)
- Cache customSearchWithExtension results (2 min TTL)
- Prevent duplicate in-flight requests for same URL/query

Dependency cleanup:
- Remove unused sqflite_common_ffi and sqlite3 packages
This commit is contained in:
zarzet
2026-05-15 00:54:58 +07:00
parent 629eb66595
commit 012dcdc2dd
7 changed files with 314 additions and 46 deletions
@@ -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(
+2 -2
View File
@@ -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)
+10 -5
View File
@@ -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,
}
+20
View File
@@ -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()
+130 -14
View File
@@ -6320,14 +6320,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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,
+138 -6
View File
@@ -21,6 +21,15 @@ class _BridgeCacheEntry {
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
class _BridgeListCacheEntry {
final List<Map<String, dynamic>> value;
final DateTime expiresAt;
const _BridgeListCacheEntry({required this.value, required this.expiresAt});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
class _BridgeInFlight<T> {
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<String, _BridgeCacheEntry> _metadataCache = {};
static final Map<String, _BridgeCacheEntry> _availabilityCache = {};
static final Map<String, _BridgeCacheEntry> _urlHandleCache = {};
static final Map<String, _BridgeListCacheEntry> _customSearchCache = {};
static final Map<String, Future<Map<String, dynamic>>> _metadataInFlight = {};
static final Map<String, Future<Map<String, dynamic>>> _availabilityInFlight =
{};
static final Map<String, Future<Map<String, dynamic>?>> _urlHandleInFlight =
{};
static final Map<String, _BridgeInFlight<List<Map<String, dynamic>>>>
_customSearchInFlight = {};
static final Map<String, _BridgeInFlight<Map<String, dynamic>?>>
@@ -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<List<Map<String, dynamic>>>(
@@ -1422,14 +1453,115 @@ class PlatformBridge {
static Future<Map<String, dynamic>?> 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<String, dynamic> 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<String, _BridgeCacheEntry> cache,
String key,
Map<String, dynamic> 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<Map<String, dynamic>>? _getCachedMapList(
Map<String, _BridgeListCacheEntry> 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<String, _BridgeListCacheEntry> cache,
String key,
List<Map<String, dynamic>> 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<String, _BridgeListCacheEntry> cache,
) {
if (cache.isEmpty) return;
final now = DateTime.now();
cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
}
static Future<String?> findURLHandler(String url) async {
-16
View File
@@ -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: