diff --git a/go_backend/exports.go b/go_backend/exports.go index 72edd394..08efb1fb 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -784,6 +784,7 @@ func CleanupConnections() { func ReadFileMetadata(filePath string) (string, error) { lower := strings.ToLower(filePath) isFlac := strings.HasSuffix(lower, ".flac") + isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") isMp3 := strings.HasSuffix(lower, ".mp3") isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") @@ -833,6 +834,12 @@ func ReadFileMetadata(filePath string) (string, error) { result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) } } + } else if isM4A { + quality, qualityErr := GetM4AQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + } } else if isMp3 { meta, err := ReadID3Tags(filePath) if err == nil && meta != nil { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 999a65a0..1e69a2fa 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -251,9 +251,11 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; + static const int _audioMetadataBackfillMaxPerLaunch = 24; final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; bool _isSafRepairInProgress = false; + bool _isAudioMetadataBackfillInProgress = false; @override DownloadHistoryState build() { @@ -298,9 +300,19 @@ class DownloadHistoryNotifier extends Notifier { maxItems: _safRepairMaxPerLaunch, ); await cleanupOrphanedDownloads(); + await _backfillAudioMetadata( + state.items, + maxItems: _audioMetadataBackfillMaxPerLaunch, + ); }); } else { - Future.microtask(() => cleanupOrphanedDownloads()); + Future.microtask(() async { + await cleanupOrphanedDownloads(); + await _backfillAudioMetadata( + state.items, + maxItems: _audioMetadataBackfillMaxPerLaunch, + ); + }); } } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); @@ -429,6 +441,157 @@ class DownloadHistoryNotifier extends Notifier { } } + int? _readPositiveInt(dynamic value) { + if (value == null) return null; + if (value is num) { + final asInt = value.toInt(); + return asInt > 0 ? asInt : null; + } + final parsed = int.tryParse(value.toString()); + if (parsed == null || parsed <= 0) return null; + return parsed; + } + + bool _supportsAudioMetadataProbe(String filePath) { + final trimmed = filePath.trim().toLowerCase(); + if (trimmed.isEmpty) return false; + if (trimmed.startsWith('content://')) return true; + return trimmed.endsWith('.flac') || + trimmed.endsWith('.m4a') || + trimmed.endsWith('.aac') || + trimmed.endsWith('.mp3') || + trimmed.endsWith('.opus') || + trimmed.endsWith('.ogg'); + } + + bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) { + if (!_supportsAudioMetadataProbe(item.filePath)) { + return false; + } + + final trimmedPath = item.filePath.trim().toLowerCase(); + final hasResolvedSpecs = + item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null && + item.sampleRate! > 0; + final needsLosslessSpecProbe = + !hasResolvedSpecs && + (trimmedPath.endsWith('.flac') || + trimmedPath.endsWith('.m4a') || + trimmedPath.endsWith('.aac') || + trimmedPath.startsWith('content://')); + + if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { + return false; + } + + return needsLosslessSpecProbe || + isPlaceholderQualityLabel(item.quality) || + normalizeOptionalString(item.quality) == null; + } + + Future?> _probeAudioMetadata( + String filePath, { + String? fallbackQuality, + }) async { + if (!_supportsAudioMetadataProbe(filePath)) { + return null; + } + + try { + final result = await PlatformBridge.readFileMetadata(filePath); + if (result['error'] != null) { + return null; + } + + final bitDepth = _readPositiveInt(result['bit_depth']); + final sampleRate = _readPositiveInt(result['sample_rate']); + final quality = buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + storedQuality: fallbackQuality, + ); + + if (quality == null && bitDepth == null && sampleRate == null) { + return null; + } + + return { + 'quality': quality, + 'bitDepth': bitDepth, + 'sampleRate': sampleRate, + }; + } catch (e) { + _historyLog.d('Audio metadata probe failed for $filePath: $e'); + return null; + } + } + + Future _backfillAudioMetadata( + List items, { + required int maxItems, + }) async { + if (_isAudioMetadataBackfillInProgress || items.isEmpty) { + return; + } + _isAudioMetadataBackfillInProgress = true; + + try { + var refreshedCount = 0; + + for (final item in items) { + if (refreshedCount >= maxItems) { + break; + } + if (!_shouldBackfillAudioMetadata(item)) { + continue; + } + + final probed = await _probeAudioMetadata( + item.filePath, + fallbackQuality: item.quality, + ); + if (probed == null) { + continue; + } + + final resolvedQuality = normalizeOptionalString( + probed['quality'] as String?, + ); + final resolvedBitDepth = probed['bitDepth'] as int?; + final resolvedSampleRate = probed['sampleRate'] as int?; + + final qualityChanged = + resolvedQuality != null && resolvedQuality != item.quality; + final bitDepthChanged = + resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; + final sampleRateChanged = + resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; + + if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) { + continue; + } + + await updateAudioMetadataForItem( + id: item.id, + quality: resolvedQuality, + bitDepth: resolvedBitDepth, + sampleRate: resolvedSampleRate, + ); + refreshedCount++; + } + + if (refreshedCount > 0) { + _historyLog.i( + 'Audio metadata backfill refreshed $refreshedCount items', + ); + } + } finally { + _isAudioMetadataBackfillInProgress = false; + } + } + Future reloadFromStorage() async { await _loadFromDatabase(); } @@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier { return DownloadHistoryItem.fromJson(json); } + Future updateAudioMetadataForItem({ + required String id, + String? quality, + int? bitDepth, + int? sampleRate, + }) async { + final index = state.items.indexWhere((item) => item.id == id); + if (index < 0) return; + + final current = state.items[index]; + final updated = current.copyWith( + quality: quality, + bitDepth: bitDepth, + sampleRate: sampleRate, + ); + + if (updated.quality == current.quality && + updated.bitDepth == current.bitDepth && + updated.sampleRate == current.sampleRate) { + return; + } + + final updatedItems = [...state.items]; + updatedItems[index] = updated; + state = state.copyWith(items: updatedItems); + await _db.updateAudioMetadata( + id, + newQuality: quality, + newBitDepth: bitDepth, + newSampleRate: sampleRate, + ); + } + Future updateMetadataForItem({ required String id, required String trackName, @@ -3496,9 +3692,7 @@ class DownloadQueueNotifier extends Notifier { final decryptionKey = (result['decryption_key'] as String?)?.trim() ?? ''; - if (!wasExisting && - decryptionKey.isNotEmpty && - filePath != null) { + if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { _log.i('Encrypted stream detected, decrypting via FFmpeg...'); updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); @@ -4331,6 +4525,50 @@ class DownloadQueueNotifier extends Notifier { normalizeOptionalString(copyright) ?? normalizeOptionalString(existingInHistory?.copyright); + int? finalBitDepth = backendBitDepth; + int? finalSampleRate = backendSampleRate; + final lowerFilePath = filePath.toLowerCase(); + final canProbeFinalMetadata = + filePath.startsWith('content://') || + lowerFilePath.endsWith('.flac') || + lowerFilePath.endsWith('.m4a') || + lowerFilePath.endsWith('.aac') || + lowerFilePath.endsWith('.mp3') || + lowerFilePath.endsWith('.opus') || + lowerFilePath.endsWith('.ogg'); + + if (canProbeFinalMetadata) { + try { + final metadata = await PlatformBridge.readFileMetadata(filePath); + if (metadata['error'] == null) { + final probedBitDepth = metadata['bit_depth'] is num + ? (metadata['bit_depth'] as num).toInt() + : int.tryParse(metadata['bit_depth']?.toString() ?? ''); + final probedSampleRate = metadata['sample_rate'] is num + ? (metadata['sample_rate'] as num).toInt() + : int.tryParse(metadata['sample_rate']?.toString() ?? ''); + + if (probedBitDepth != null && probedBitDepth > 0) { + finalBitDepth = probedBitDepth; + } + if (probedSampleRate != null && probedSampleRate > 0) { + finalSampleRate = probedSampleRate; + } + + final resolvedQuality = buildDisplayAudioQuality( + bitDepth: finalBitDepth, + sampleRate: finalSampleRate, + storedQuality: actualQuality, + ); + if (resolvedQuality != null) { + actualQuality = resolvedQuality; + } + } + } catch (e) { + _log.d('Final audio metadata probe failed for $filePath: $e'); + } + } + _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); final historyAlbumArtist = @@ -4338,9 +4576,12 @@ class DownloadQueueNotifier extends Notifier { ? resolvedAlbumArtist : null; - final isMp3 = filePath.endsWith('.mp3'); - final historyBitDepth = isMp3 ? null : backendBitDepth; - final historySampleRate = isMp3 ? null : backendSampleRate; + final isLossyOutput = + lowerFilePath.endsWith('.mp3') || + lowerFilePath.endsWith('.opus') || + lowerFilePath.endsWith('.ogg'); + final historyBitDepth = isLossyOutput ? null : finalBitDepth; + final historySampleRate = isLossyOutput ? null : finalSampleRate; ref .read(downloadHistoryProvider.notifier) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 774a6e26..9299320c 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -29,6 +29,7 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; enum LibraryItemSource { downloaded, local } @@ -70,7 +71,11 @@ class UnifiedLibraryItem { albumName: item.albumName, coverUrl: item.coverUrl, filePath: item.filePath, - quality: item.quality, + quality: buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + storedQuality: item.quality, + ), addedAt: item.downloadedAt, source: LibraryItemSource.downloaded, historyItem: item, @@ -80,15 +85,18 @@ class UnifiedLibraryItem { factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { String? quality; if (item.bitrate != null && item.bitrate! > 0) { - // Lossy format with bitrate - final fmt = item.format?.toUpperCase() ?? ''; - quality = '$fmt ${item.bitrate}kbps'.trim(); + quality = buildDisplayAudioQuality( + bitrateKbps: item.bitrate, + format: item.format, + ); } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { // Lossless format with actual bit depth - quality = - '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; + quality = buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + ); } return UnifiedLibraryItem( id: 'local_${item.id}', diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d39c04bc..b4a3238e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -66,6 +66,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool _isInstrumental = false; // Track if detected as instrumental bool _isConverting = false; // Track convert operation in progress bool _hasMetadataChanges = false; + bool _hasLoadedResolvedAudioMetadata = false; Map? _editedMetadata; // Overrides after metadata edit String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); @@ -240,6 +241,12 @@ class _TrackMetadataScreenState extends ConsumerState { if (mounted && exists && _lyrics == null && !_lyricsLoading) { _fetchLyrics(); } + if (mounted && + exists && + !_isLocalItem && + !_hasLoadedResolvedAudioMetadata) { + unawaited(_refreshResolvedAudioMetadataFromFile()); + } if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) { final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid( _coverCacheKey, @@ -274,6 +281,61 @@ class _TrackMetadataScreenState extends ConsumerState { await _cleanupTempFileAndParent(path); } + Future _refreshResolvedAudioMetadataFromFile() async { + if (_isLocalItem || + _downloadItem == null || + _hasLoadedResolvedAudioMetadata) { + return; + } + + _hasLoadedResolvedAudioMetadata = true; + + try { + final metadata = await PlatformBridge.readFileMetadata(cleanFilePath); + if (metadata['error'] != null) { + return; + } + + final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']); + final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']); + final resolvedQuality = buildDisplayAudioQuality( + bitDepth: resolvedBitDepth ?? bitDepth, + sampleRate: resolvedSampleRate ?? sampleRate, + storedQuality: _quality, + ); + final shouldPersistResolvedAudioMetadata = + resolvedBitDepth != null || + resolvedSampleRate != null || + (isPlaceholderQualityLabel(_quality) && resolvedQuality != null); + + if ((resolvedBitDepth != null || + resolvedSampleRate != null || + isPlaceholderQualityLabel(_quality)) && + mounted) { + setState(() { + _editedMetadata = { + ...?_editedMetadata, + if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth, + if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, + }; + }); + } + + if (shouldPersistResolvedAudioMetadata) { + await ref + .read(downloadHistoryProvider.notifier) + .updateAudioMetadataForItem( + id: _downloadItem!.id, + quality: resolvedQuality, + bitDepth: resolvedBitDepth, + sampleRate: resolvedSampleRate, + ); + } + } catch (e) { + _log.w('Failed to resolve audio metadata from file: $e'); + } + } + void _cleanupTempFileAndParentSync(String? path) { if (!_hasPath(path)) return; final file = File(path!); @@ -426,9 +488,13 @@ class _TrackMetadataScreenState extends ConsumerState { int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; int? get bitDepth => - _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; + _readPositiveInt(_editedMetadata?['bit_depth']) ?? + (_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth); int? get sampleRate => - _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; + _readPositiveInt(_editedMetadata?['sample_rate']) ?? + (_isLocalItem + ? _localLibraryItem!.sampleRate + : _downloadItem!.sampleRate); int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null; String get _filePath => @@ -452,6 +518,32 @@ class _TrackMetadataScreenState extends ConsumerState { String? get _quality => _isLocalItem ? null : _downloadItem!.quality; + int? _readPositiveInt(dynamic value) { + if (value == null) return null; + if (value is num) { + final asInt = value.toInt(); + return asInt > 0 ? asInt : null; + } + final parsed = int.tryParse(value.toString()); + if (parsed == null || parsed <= 0) return null; + return parsed; + } + + String? get _displayAudioQuality { + final fileName = _extractFileNameFromPathOrUri(cleanFilePath); + final fileExt = fileName.contains('.') + ? fileName.split('.').last.toUpperCase() + : null; + + return buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + bitrateKbps: _isLocalItem ? _localBitrate : null, + format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt, + storedQuality: _quality, + ); + } + String get cleanFilePath { final path = _filePath; return path.startsWith('EXISTS:') ? path.substring(7) : path; @@ -723,7 +815,8 @@ class _TrackMetadataScreenState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - if (_quality != null && _quality!.isNotEmpty) + if (_displayAudioQuality != null && + _displayAudioQuality!.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -734,7 +827,7 @@ class _TrackMetadataScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), child: Text( - _quality!, + _displayAudioQuality!, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, @@ -961,34 +1054,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { - // Determine audio quality string - prefer stored quality from download - String? audioQualityStr; - final fileName = _extractFileNameFromPathOrUri(cleanFilePath); - final fileExt = fileName.contains('.') - ? fileName.split('.').last.toUpperCase() - : ''; - - // Use stored quality from download history if available - if (_quality != null && _quality!.isNotEmpty) { - audioQualityStr = _quality; - } else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) { - // Lossy local file with bitrate info - final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt; - audioQualityStr = '$fmt ${_localBitrate}kbps'; - } else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) { - // Lossless file with actual bit depth (FLAC, ALAC) - final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); - audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; - } else { - // Fallback based on file extension for legacy items - if (fileExt == 'MP3') { - audioQualityStr = 'MP3'; - } else if (fileExt == 'OPUS' || fileExt == 'OGG') { - audioQualityStr = 'Opus'; - } else if (fileExt == 'M4A' || fileExt == 'AAC') { - audioQualityStr = 'AAC'; - } - } + final audioQualityStr = _displayAudioQuality; final items = <_MetadataItem>[ _MetadataItem(context.l10n.trackTrackName, trackName), @@ -1090,7 +1156,8 @@ class _TrackMetadataScreenState extends ConsumerState { final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; - final lossyBitrateLabel = _extractLossyBitrateLabel(_quality); + final resolvedQuality = _displayAudioQuality; + final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); return Card( elevation: 0, @@ -1220,7 +1287,11 @@ class _TrackMetadataScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), child: Text( - '$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz', + buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + ) ?? + '', style: TextStyle( color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 619b424d..75bfaada 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -498,6 +498,29 @@ class HistoryDatabase { await db.update('history', values, where: 'id = ?', whereArgs: [id]); } + Future updateAudioMetadata( + String id, { + String? newQuality, + int? newBitDepth, + int? newSampleRate, + }) async { + final db = await database; + final values = {}; + if (newQuality != null) { + values['quality'] = newQuality; + } + if (newBitDepth != null) { + values['bit_depth'] = newBitDepth; + } + if (newSampleRate != null) { + values['sample_rate'] = newSampleRate; + } + if (values.isEmpty) { + return; + } + await db.update('history', values, where: 'id = ?', whereArgs: [id]); + } + /// Get all file paths from download history /// Used to exclude downloaded files from local library scan Future> getAllFilePaths() async { diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 8394d430..9f4cc164 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -5,3 +5,47 @@ String? normalizeOptionalString(String? value) { if (trimmed.toLowerCase() == 'null') return null; return trimmed; } + +String formatSampleRateKHz(int sampleRate) { + final khz = sampleRate / 1000; + final precision = sampleRate % 1000 == 0 ? 0 : 1; + return '${khz.toStringAsFixed(precision)}kHz'; +} + +String? buildDisplayAudioQuality({ + int? bitDepth, + int? sampleRate, + int? bitrateKbps, + String? format, + String? storedQuality, +}) { + if (bitrateKbps != null && bitrateKbps > 0) { + final normalizedFormat = normalizeOptionalString(format)?.toUpperCase(); + return normalizedFormat != null + ? '$normalizedFormat ${bitrateKbps}kbps' + : '${bitrateKbps}kbps'; + } + + if (bitDepth != null && + bitDepth > 0 && + sampleRate != null && + sampleRate > 0) { + return '$bitDepth-bit/${formatSampleRateKHz(sampleRate)}'; + } + + return normalizeOptionalString(storedQuality); +} + +bool isPlaceholderQualityLabel(String? quality) { + final normalized = normalizeOptionalString(quality)?.toLowerCase(); + if (normalized == null) return false; + + return const { + 'best', + 'lossless', + 'hi-res', + 'hi-res-max', + 'high', + 'cd', + }.contains(normalized); +}