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
+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 {