feat: resolve audio metadata from file, backfill placeholder quality labels with actual bit depth and sample rate

This commit is contained in:
zarzet
2026-03-08 04:28:16 +07:00
parent 7d5cb574c6
commit 2c897992c5
6 changed files with 441 additions and 47 deletions
+7
View File
@@ -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 {
+248 -7
View File
@@ -251,9 +251,11 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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<DownloadHistoryState> {
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<DownloadHistoryState> {
}
}
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<Map<String, dynamic>?> _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<void> _backfillAudioMetadata(
List<DownloadHistoryItem> 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<void> reloadFromStorage() async {
await _loadFromDatabase();
}
@@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
Future<void> 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<void> updateMetadataForItem({
required String id,
required String trackName,
@@ -3496,9 +3692,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
? 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)
+14 -6
View File
@@ -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}',
+105 -34
View File
@@ -66,6 +66,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _isInstrumental = false; // Track if detected as instrumental
bool _isConverting = false; // Track convert operation in progress
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
@@ -240,6 +241,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
await _cleanupTempFileAndParent(path);
}
Future<void> _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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
}
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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,
+23
View File
@@ -498,6 +498,29 @@ class HistoryDatabase {
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
Future<void> updateAudioMetadata(
String id, {
String? newQuality,
int? newBitDepth,
int? newSampleRate,
}) async {
final db = await database;
final values = <String, dynamic>{};
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<Set<String>> getAllFilePaths() async {
+44
View File
@@ -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);
}