From 718e13f24e01fab7feb29ce438f4e87441f3f7c9 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:23:58 +0700 Subject: [PATCH] feat(library): filter tracks missing verified ReplayGain tags --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 37 +++++++- lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_id.arb | 4 + lib/providers/download_history_models.dart | 18 ++++ lib/providers/download_history_provider.dart | 18 +++- ...download_history_provider_maintenance.dart | 21 ++++- .../download_queue_provider_finalization.dart | 22 ++++- ...download_queue_provider_native_worker.dart | 6 ++ .../download_queue_provider_single_item.dart | 3 + lib/screens/queue_tab.dart | 11 +++ lib/screens/track_metadata_screen.dart | 28 ++++++ lib/services/batch_track_actions.dart | 33 ++++++- lib/services/history_database.dart | 31 ++++++- lib/services/library_database.dart | 65 +++++++++++-- lib/services/library_database_models.dart | 7 ++ lib/services/library_database_queue_sql.dart | 35 +++++++ lib/utils/audio_format_utils.dart | 26 ++++++ test/models_and_utils_test.dart | 93 +++++++++++++++++++ test/replaygain_library_filter_test.dart | 75 +++++++++++++++ test/track_metadata_replaygain_test.dart | 51 ++++++++++ 20 files changed, 568 insertions(+), 20 deletions(-) create mode 100644 test/replaygain_library_filter_test.dart 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 55b042e8..6d5a1f04 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -37,7 +37,7 @@ object NativeDownloadFinalizer { const val NATIVE_WORKER_CONTRACT_VERSION = 1 // Native finalizer owns background-safe history writes while Flutter may be suspended. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. - const val HISTORY_SCHEMA_VERSION = 13 + const val HISTORY_SCHEMA_VERSION = 14 // Keep one native connection for the process. Opening history.db and // probing/migrating its schema for every finalized track was expensive, // and a single guarded writer also prevents native finalizer calls from @@ -98,6 +98,8 @@ object NativeDownloadFinalizer { "explicit", "has_lyrics", "lyrics_metadata_scan_version", + "has_replaygain", + "replaygain_metadata_scan_version", "spotify_id_norm", "isrc_norm", "match_key", @@ -150,6 +152,8 @@ object NativeDownloadFinalizer { var lyricsMetadataScanned: Boolean = false, var hasEmbeddedLyrics: Boolean = false, var externalLrcWritten: Boolean = false, + var replayGainMetadataScanned: Boolean = false, + var hasReplayGain: Boolean = false, ) internal data class ReplayGainScan( @@ -273,7 +277,11 @@ object NativeDownloadFinalizer { val replayGain = timedStage("ReplayGain") { writeReplayGain(context, effectiveInput, state, shouldCancel) } - if (replayGain != null) result.put("replaygain", replayGain) + if (replayGain != null) { + result.put("replaygain", replayGain) + state.replayGainMetadataScanned = true + state.hasReplayGain = true + } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -1004,6 +1012,19 @@ object NativeDownloadFinalizer { val metadata = parseObject(createCoreBackend(context).readFileMetadata(probePath, state.fileName)) if (metadata.has("error")) return + if (!metadata.optBoolean("metadataFromFilename", false) && + (metadata.has("audio_codec") || metadata.has("format") || + metadata.has("replaygain_track_gain") || metadata.has("replaygain_album_gain")) + ) { + state.replayGainMetadataScanned = true + state.hasReplayGain = listOf("replaygain_track_gain", "replaygain_album_gain").any { key -> + val value = metadata.optString(key, "").trim() + val gain = (if (value.endsWith("dB", ignoreCase = true)) value.dropLast(2) else value) + .trim().toDoubleOrNull() + gain != null && gain.isFinite() + } + } + if (metadata.has("lyrics") || metadata.has("hasLyrics")) { state.lyricsMetadataScanned = true state.hasEmbeddedLyrics = @@ -1343,6 +1364,8 @@ object NativeDownloadFinalizer { ) 1 else 0, ) putNormalizedHistoryColumns(values) + values.put("has_replaygain", if (state.hasReplayGain) 1 else 0) + values.put("replaygain_metadata_scan_version", if (state.replayGainMetadataScanned) 1 else 0) return values } @@ -1361,7 +1384,9 @@ object NativeDownloadFinalizer { "history schema v${db.version} is newer than native finalizer contract v$HISTORY_SCHEMA_VERSION" ) } - val needsBackfill = db.version < HISTORY_SCHEMA_VERSION + // v14 only adds gain flags; v13 already has normalized keys. + // Avoid walking the entire history for this additive upgrade. + val needsBackfill = db.version < 13 db.execSQL( """ CREATE TABLE IF NOT EXISTS history ( @@ -1399,6 +1424,8 @@ object NativeDownloadFinalizer { explicit INTEGER NOT NULL DEFAULT 0, has_lyrics INTEGER NOT NULL DEFAULT 0, lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0, + has_replaygain INTEGER NOT NULL DEFAULT 0, + replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0, spotify_id_norm TEXT, isrc_norm TEXT, match_key TEXT, @@ -1439,6 +1466,8 @@ object NativeDownloadFinalizer { ensureHistoryColumn(db, "explicit", "ALTER TABLE history ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0") ensureHistoryColumn(db, "has_lyrics", "ALTER TABLE history ADD COLUMN has_lyrics INTEGER NOT NULL DEFAULT 0") ensureHistoryColumn(db, "lyrics_metadata_scan_version", "ALTER TABLE history ADD COLUMN lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0") + ensureHistoryColumn(db, "has_replaygain", "ALTER TABLE history ADD COLUMN has_replaygain INTEGER NOT NULL DEFAULT 0") + ensureHistoryColumn(db, "replaygain_metadata_scan_version", "ALTER TABLE history ADD COLUMN replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0") ensureHistoryPathKeyTable(db) if (needsBackfill) { backfillNormalizedHistoryColumns(db) @@ -2006,6 +2035,8 @@ object NativeDownloadFinalizer { json.put("explicit", values.getAsInteger("explicit") == 1) json.put("hasLyrics", values.getAsInteger("has_lyrics") == 1) json.put("lyricsMetadataScanVersion", values.getAsInteger("lyrics_metadata_scan_version") ?: 0) + json.put("hasReplayGain", values.getAsInteger("has_replaygain") == 1) + json.put("replayGainMetadataScanVersion", values.getAsInteger("replaygain_metadata_scan_version") ?: 0) return json } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 27fa040b..3352b197 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6943,6 +6943,10 @@ "@libraryFilterMetadataMissingLyrics": { "description": "Filter option for tracks without embedded or sidecar lyrics" }, + "libraryFilterMetadataMissingReplayGain": "Missing ReplayGain", + "@libraryFilterMetadataMissingReplayGain": { + "description": "Filter option for scanned tracks without ReplayGain gain tags" + }, "trackOptionCopyTrackName": "Copy track name", "@trackOptionCopyTrackName": { "description": "Track menu action that copies the track title" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 5d84bc69..257def1c 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6824,6 +6824,10 @@ "@libraryFilterMetadataMissingLyrics": { "description": "Filter option for tracks without embedded or sidecar lyrics" }, + "libraryFilterMetadataMissingReplayGain": "Tanpa ReplayGain", + "@libraryFilterMetadataMissingReplayGain": { + "description": "Filter option for scanned tracks without ReplayGain gain tags" + }, "trackOptionCopyTrackName": "Salin nama lagu", "@trackOptionCopyTrackName": { "description": "Track menu action that copies the track title" diff --git a/lib/providers/download_history_models.dart b/lib/providers/download_history_models.dart index f74cb6e3..30582b6d 100644 --- a/lib/providers/download_history_models.dart +++ b/lib/providers/download_history_models.dart @@ -35,6 +35,11 @@ class DownloadHistoryItem { final bool explicit; final bool hasLyrics; final int lyricsMetadataScanVersion; + final bool hasReplayGain; + + /// 0 until the finished file's tags were read; only then is a false + /// [hasReplayGain] a confirmed absence. + final int replayGainMetadataScanVersion; const DownloadHistoryItem({ required this.id, @@ -71,6 +76,8 @@ class DownloadHistoryItem { this.explicit = false, this.hasLyrics = false, this.lyricsMetadataScanVersion = 0, + this.hasReplayGain = false, + this.replayGainMetadataScanVersion = 0, }); Map toJson() => { @@ -108,6 +115,8 @@ class DownloadHistoryItem { 'explicit': explicit, 'hasLyrics': hasLyrics, 'lyricsMetadataScanVersion': lyricsMetadataScanVersion, + 'hasReplayGain': hasReplayGain, + 'replayGainMetadataScanVersion': replayGainMetadataScanVersion, }; factory DownloadHistoryItem.fromJson(Map json) => @@ -147,6 +156,10 @@ class DownloadHistoryItem { hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1, lyricsMetadataScanVersion: (json['lyricsMetadataScanVersion'] as num?)?.toInt() ?? 0, + hasReplayGain: + json['hasReplayGain'] == true || json['hasReplayGain'] == 1, + replayGainMetadataScanVersion: + (json['replayGainMetadataScanVersion'] as num?)?.toInt() ?? 0, ); DownloadHistoryItem copyWith({ @@ -181,6 +194,8 @@ class DownloadHistoryItem { bool? explicit, bool? hasLyrics, int? lyricsMetadataScanVersion, + bool? hasReplayGain, + int? replayGainMetadataScanVersion, }) { return DownloadHistoryItem( id: id, @@ -218,6 +233,9 @@ class DownloadHistoryItem { hasLyrics: hasLyrics ?? this.hasLyrics, lyricsMetadataScanVersion: lyricsMetadataScanVersion ?? this.lyricsMetadataScanVersion, + hasReplayGain: hasReplayGain ?? this.hasReplayGain, + replayGainMetadataScanVersion: + replayGainMetadataScanVersion ?? this.replayGainMetadataScanVersion, ); } } diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 2687d911..16b8f042 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -214,6 +214,15 @@ class DownloadHistoryNotifier extends Notifier { item.lyricsMetadataScanVersion, existing.lyricsMetadataScanVersion, ), + hasReplayGain: + item.replayGainMetadataScanVersion >= + existing.replayGainMetadataScanVersion + ? item.hasReplayGain + : existing.hasReplayGain, + replayGainMetadataScanVersion: max( + item.replayGainMetadataScanVersion, + existing.replayGainMetadataScanVersion, + ), ); return (item: mergedItem, existingId: existing?.id); } @@ -493,6 +502,8 @@ class DownloadHistoryNotifier extends Notifier { bool? explicit, bool? hasLyrics, int? lyricsMetadataScanVersion, + bool? hasReplayGain, + int? replayGainMetadataScanVersion, }) async { final target = await _historyItemForUpdate(id); if (target == null) { @@ -518,6 +529,8 @@ class DownloadHistoryNotifier extends Notifier { explicit: explicit, hasLyrics: hasLyrics, lyricsMetadataScanVersion: lyricsMetadataScanVersion, + hasReplayGain: hasReplayGain, + replayGainMetadataScanVersion: replayGainMetadataScanVersion, ); if (updated.quality == current.quality && @@ -534,7 +547,10 @@ class DownloadHistoryNotifier extends Notifier { updated.explicit == current.explicit && updated.hasLyrics == current.hasLyrics && updated.lyricsMetadataScanVersion == - current.lyricsMetadataScanVersion) { + current.lyricsMetadataScanVersion && + updated.hasReplayGain == current.hasReplayGain && + updated.replayGainMetadataScanVersion == + current.replayGainMetadataScanVersion) { return; } diff --git a/lib/providers/download_history_provider_maintenance.dart b/lib/providers/download_history_provider_maintenance.dart index 1d643784..c3801bfd 100644 --- a/lib/providers/download_history_provider_maintenance.dart +++ b/lib/providers/download_history_provider_maintenance.dart @@ -272,6 +272,7 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) { return item.lyricsMetadataScanVersion < 1 || + item.replayGainMetadataScanVersion < 1 || _needsAverageBitrateBackfill(item) || _shouldBackfillAudioMetadataIgnoringBitrate(item); } @@ -506,6 +507,10 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { 'totalDiscs': totalDiscs, 'hasLyrics': hasLyrics, 'lyricsMetadataScanVersion': lyricsMetadataScanVersion, + if (replayGainMetadataWasRead(result)) ...{ + 'hasReplayGain': metadataHasReplayGain(result), + 'replayGainMetadataScanVersion': 1, + }, }; } catch (e) { _historyLog.d('Audio metadata probe failed for $filePath: $e'); @@ -562,6 +567,7 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { Map? probed; if (item.lyricsMetadataScanVersion < 1 || + item.replayGainMetadataScanVersion < 1 || _shouldBackfillAudioMetadataIgnoringBitrate(item)) { probed = await _probeAudioMetadata( item, @@ -609,6 +615,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { final resolvedHasLyrics = probed['hasLyrics'] as bool?; final resolvedLyricsScanVersion = probed['lyricsMetadataScanVersion'] as int?; + final resolvedHasReplayGain = probed['hasReplayGain'] as bool?; + final resolvedReplayGainScanVersion = + probed['replayGainMetadataScanVersion'] as int?; final qualityChanged = resolvedQuality != null && resolvedQuality != item.quality; @@ -639,6 +648,12 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { final lyricsScanVersionChanged = resolvedLyricsScanVersion != null && resolvedLyricsScanVersion != item.lyricsMetadataScanVersion; + final replayGainChanged = + resolvedHasReplayGain != null && + resolvedHasReplayGain != item.hasReplayGain; + final replayGainScanVersionChanged = + resolvedReplayGainScanVersion != null && + resolvedReplayGainScanVersion != item.replayGainMetadataScanVersion; if (!qualityChanged && !bitDepthChanged && @@ -652,7 +667,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { !discNumberChanged && !totalDiscsChanged && !hasLyricsChanged && - !lyricsScanVersionChanged) { + !lyricsScanVersionChanged && + !replayGainChanged && + !replayGainScanVersionChanged) { continue; } @@ -670,6 +687,8 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { totalDiscs: resolvedTotalDiscs, hasLyrics: resolvedHasLyrics, lyricsMetadataScanVersion: resolvedLyricsScanVersion, + hasReplayGain: resolvedHasReplayGain, + replayGainMetadataScanVersion: resolvedReplayGainScanVersion, ); updatedItems ??= [...items]; updatedItems[index] = updated; diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index 3e2a93cb..2e9cb7ea 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -308,6 +308,8 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { String? copyright, required bool hasLyrics, required int lyricsMetadataScanVersion, + required bool hasReplayGain, + required int replayGainMetadataScanVersion, }) { final backendTitle = result['title'] as String?; final backendArtist = result['artist'] as String?; @@ -394,19 +396,35 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { parseExplicitFlag(result['explicit']) == true, hasLyrics: hasLyrics, lyricsMetadataScanVersion: lyricsMetadataScanVersion, + hasReplayGain: hasReplayGain, + replayGainMetadataScanVersion: replayGainMetadataScanVersion, ); } - Future<({bool hasLyrics, int scanVersion})> _resolveFinalLyricsAvailability({ + Future< + ({ + bool hasLyrics, + int scanVersion, + bool hasReplayGain, + int replayGainScanVersion, + }) + > + _resolveFinalLyricsAvailability({ required String filePath, Map? probedMetadata, bool externalLrcWritten = false, }) async { var metadataScanned = false; var hasEmbeddedLyrics = false; + var hasReplayGain = false; + var replayGainScanVersion = 0; try { final metadata = probedMetadata ?? await PlatformBridge.readFileMetadata(filePath); + if (replayGainMetadataWasRead(metadata)) { + hasReplayGain = metadataHasReplayGain(metadata); + replayGainScanVersion = 1; + } if (metadata['error'] == null && (metadata.containsKey('lyrics') || metadata.containsKey('hasLyrics'))) { @@ -439,6 +457,8 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { return ( hasLyrics: hasLyrics, scanVersion: hasLyrics || metadataScanned ? 1 : 0, + hasReplayGain: hasReplayGain, + replayGainScanVersion: replayGainScanVersion, ); } diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 89eb4bbd..126cd866 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -194,6 +194,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { copyright: normalizeOptionalString(result['copyright']?.toString()), hasLyrics: lyricsAvailability.hasLyrics, lyricsMetadataScanVersion: lyricsAvailability.scanVersion, + hasReplayGain: lyricsAvailability.hasReplayGain, + replayGainMetadataScanVersion: + lyricsAvailability.replayGainScanVersion, ), preserveTrackVariant: context.item.preserveQualityVariant, ); @@ -1555,6 +1558,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { ), hasLyrics: lyricsAvailability.hasLyrics, lyricsMetadataScanVersion: lyricsAvailability.scanVersion, + hasReplayGain: lyricsAvailability.hasReplayGain, + replayGainMetadataScanVersion: + lyricsAvailability.replayGainScanVersion, ), preserveTrackVariant: item.preserveQualityVariant, ); diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index f97428ab..0ae007ab 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -1673,6 +1673,9 @@ class _DownloadRun { copyright: effectiveCopyright, hasLyrics: lyricsAvailability.hasLyrics, lyricsMetadataScanVersion: lyricsAvailability.scanVersion, + hasReplayGain: lyricsAvailability.hasReplayGain, + replayGainMetadataScanVersion: + lyricsAvailability.replayGainScanVersion, ), preserveTrackVariant: item.preserveQualityVariant, ); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 2587cdc2..9cf6cd22 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1070,6 +1070,17 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-lyrics', ), ), + AppChoiceChip( + label: Text( + context + .l10n + .libraryFilterMetadataMissingReplayGain, + ), + selected: tempMetadata == 'missing-replaygain', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-replaygain', + ), + ), ], ), const SizedBox(height: 16), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 443f3edf..7e996c34 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -381,6 +381,14 @@ class _TrackMetadataScreenState extends ConsumerState ? resolvedHasLyrics != _localLibraryItem?.hasLyrics : resolvedHasLyrics != _downloadItem?.hasLyrics || (_downloadItem?.lyricsMetadataScanVersion ?? 0) < 1); + final resolvedHasReplayGain = metadataHasReplayGain(metadata); + final hasResolvedReplayGainValue = replayGainMetadataWasRead(metadata); + final needsReplayGainAvailability = + hasResolvedReplayGainValue && + (_isLocalItem + ? resolvedHasReplayGain != _localLibraryItem?.hasReplayGain + : resolvedHasReplayGain != _downloadItem?.hasReplayGain || + (_downloadItem?.replayGainMetadataScanVersion ?? 0) < 1); final needsTrackNumber = resolvedTrackNumber != null && resolvedTrackNumber > 0 && @@ -443,6 +451,7 @@ class _TrackMetadataScreenState extends ConsumerState needsComposer || needsExplicit || needsLyricsAvailability || + needsReplayGainAvailability || (isPlaceholderQualityLabel(_quality) && resolvedQuality != null)); final localItem = _localLibraryItem; final localAudioMetadataChanged = @@ -455,6 +464,7 @@ class _TrackMetadataScreenState extends ConsumerState resolvedBitrate != localItem.bitrate) || needsExplicit || needsLyricsAvailability || + needsReplayGainAvailability || needsDuration || formatChanged); @@ -534,6 +544,12 @@ class _TrackMetadataScreenState extends ConsumerState explicit: needsExplicit ? resolvedExplicit : null, hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null, lyricsMetadataScanVersion: hasResolvedLyricsValue ? 1 : null, + hasReplayGain: hasResolvedReplayGainValue + ? resolvedHasReplayGain + : null, + replayGainMetadataScanVersion: hasResolvedReplayGainValue + ? 1 + : null, ); if (mounted && _downloadItem != null) { setState(() { @@ -552,6 +568,12 @@ class _TrackMetadataScreenState extends ConsumerState explicit: needsExplicit ? resolvedExplicit : null, hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null, lyricsMetadataScanVersion: hasResolvedLyricsValue ? 1 : null, + hasReplayGain: hasResolvedReplayGainValue + ? resolvedHasReplayGain + : null, + replayGainMetadataScanVersion: hasResolvedReplayGainValue + ? 1 + : null, ); }); } @@ -564,6 +586,9 @@ class _TrackMetadataScreenState extends ConsumerState bitrate: resolvedBitrate, explicit: needsExplicit ? resolvedExplicit : null, hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null, + hasReplayGain: hasResolvedReplayGainValue + ? resolvedHasReplayGain + : null, format: formatChanged ? resolvedFormat : null, ); if (mounted && @@ -577,6 +602,9 @@ class _TrackMetadataScreenState extends ConsumerState bitrate: resolvedBitrate, explicit: needsExplicit ? resolvedExplicit : null, hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null, + hasReplayGain: hasResolvedReplayGainValue + ? resolvedHasReplayGain + : null, format: resolvedFormat, ); }); diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 04ae3292..beec4af6 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -542,7 +542,7 @@ Future _performBatchConversion( /// selection UI around the confirmation dialog; [onConfirmClosed] receives /// whether the user confirmed. Future runBatchReplayGain( - BuildContext context, + BuildContext sourceContext, List selectedItems, { required VoidCallback onExitSelectionMode, bool remove = false, @@ -551,6 +551,10 @@ Future runBatchReplayGain( }) async { if (selectedItems.isEmpty) return; + final container = ProviderScope.containerOf(sourceContext, listen: false); + // The selection overlay can disappear when opening the confirmation dialog. + final context = Navigator.of(sourceContext, rootNavigator: true).context; + onConfirmOpen?.call(); final confirmed = await showAppDialog( @@ -618,12 +622,37 @@ Future runBatchReplayGain( item.filePath, onUnsupportedDecoder: () => unsupportedDecoder = true, ); - if (ok) successCount++; + if (ok) { + successCount++; + try { + if (item.historyItem case final history?) { + await container + .read(downloadHistoryProvider.notifier) + .updateAudioMetadataForItem( + id: history.id, + hasReplayGain: !remove, + replayGainMetadataScanVersion: 1, + ); + } + if (item.localItem case final local?) { + await LibraryDatabase.instance.updateAudioMetadata( + local.id, + hasReplayGain: !remove, + ); + } + } catch (e) { + _batchActionsLog.w('Could not refresh ReplayGain availability: $e'); + } + } } catch (_) {} } onExitSelectionMode(); + if (successCount > 0 && context.mounted) { + await container.read(localLibraryProvider.notifier).reloadFromStorage(); + } + if (!context.mounted) return; if (!cancelled) { BatchProgressDialog.dismiss(context); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 3c7b9ff7..e6d0d77b 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/utils/isrc_utils.dart' as isrc; import 'package:spotiflac_android/utils/ios_container_paths.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; final _log = AppLogger('HistoryDatabase'); final Future _prefs = SharedPreferences.getInstance(); @@ -91,10 +92,10 @@ class _HistoryBatchLookupSnapshot extends HistoryBatchLookupRequest { class HistoryDatabase { // The FTS table is a derived, optional index and is initialized lazily after - // the existing schema migration. Keep this contract at v13 because the - // background native writer shares history.db and must accept the same + // the existing schema migration. The background native writer shares + // history.db and must accept the same schema contract and // user_version without depending on FTS5. - static const int schemaVersion = 13; + static const int schemaVersion = 14; static const String searchFtsTable = 'history_search_fts'; static final HistoryDatabase instance = HistoryDatabase._init(); static final sqlite.SingleFlightInitializer _database = @@ -162,6 +163,8 @@ class HistoryDatabase { explicit INTEGER NOT NULL DEFAULT 0, has_lyrics INTEGER NOT NULL DEFAULT 0, lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0, + has_replaygain INTEGER NOT NULL DEFAULT 0, + replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0, spotify_id_norm TEXT, isrc_norm TEXT, match_key TEXT, @@ -297,6 +300,21 @@ class HistoryDatabase { ); _log.i('Added indexed lyrics availability metadata'); } + if (oldVersion < 14) { + await sqlite.addColumnIfMissing( + db, + 'history', + 'has_replaygain', + 'INTEGER NOT NULL DEFAULT 0', + ); + await sqlite.addColumnIfMissing( + db, + 'history', + 'replaygain_metadata_scan_version', + 'INTEGER NOT NULL DEFAULT 0', + ); + _log.i('Added indexed ReplayGain availability metadata'); + } } Future _createSearchFts(DatabaseExecutor db) { @@ -678,6 +696,9 @@ class HistoryDatabase { 'lyrics_metadata_scan_version': (json['lyricsMetadataScanVersion'] as num?)?.toInt() ?? (json.containsKey('hasLyrics') ? 1 : 0), + 'has_replaygain': metadataHasReplayGain(json) ? 1 : 0, + 'replaygain_metadata_scan_version': + (json['replayGainMetadataScanVersion'] as num?)?.toInt() ?? 0, }; row.addAll( _queueSortColumns( @@ -739,6 +760,10 @@ class HistoryDatabase { 'explicit': row['explicit'] == 1 || row['explicit'] == true, 'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true, 'lyricsMetadataScanVersion': row['lyrics_metadata_scan_version'] ?? 0, + 'hasReplayGain': + row['has_replaygain'] == 1 || row['has_replaygain'] == true, + 'replayGainMetadataScanVersion': + row['replaygain_metadata_scan_version'] ?? 0, }; } diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 6f2fa96a..fc340b88 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -21,7 +21,7 @@ class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); // The FTS table is a derived, optional index and is initialized lazily after // the existing schema migration, so it does not require a user_version bump. - static const int schemaVersion = 14; + static const int schemaVersion = 15; static const String legacySourceId = LocalLibraryItem.legacySourceId; static const String visibleLibraryView = 'library_visible'; static const String searchFtsTable = 'library_search_fts'; @@ -33,7 +33,11 @@ class LibraryDatabase { 'library_incremental_path_keys_stage'; static const String _downloadedLibraryIdsStageTable = 'library_downloaded_ids_stage'; - static const int audioMetadataScanVersion = 3; + // v4 records ReplayGain availability; older rows rescan once. + static const int audioMetadataScanVersion = 4; + + /// First scan version whose rows record lyrics availability (v3). + static const int lyricsMetadataScanVersion = 3; static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); bool _historyAttached = false; @@ -190,8 +194,9 @@ class LibraryDatabase { copyright TEXT, explicit INTEGER NOT NULL DEFAULT 0, has_lyrics INTEGER NOT NULL DEFAULT 0, + has_replaygain INTEGER NOT NULL DEFAULT 0, format TEXT, - audio_metadata_scan_version INTEGER NOT NULL DEFAULT 3, + audio_metadata_scan_version INTEGER NOT NULL DEFAULT 4, track_name_norm TEXT, artist_name_norm TEXT, album_name_norm TEXT, @@ -332,6 +337,17 @@ class LibraryDatabase { await _createLookupSummary(db); _log.i('Added incremental Library lookup summary'); } + if (oldVersion < 15) { + // Rows keep their older audio_metadata_scan_version, so the next + // incremental scan re-reads them once and fills the real value. + await sqlite.addColumnIfMissing( + db, + 'library', + 'has_replaygain', + 'INTEGER NOT NULL DEFAULT 0', + ); + _log.i('Added indexed ReplayGain availability metadata'); + } } Future _createLookupSummary(DatabaseExecutor db) async { @@ -721,10 +737,11 @@ class LibraryDatabase { 'copyright': json['copyright'], 'explicit': json['explicit'] == true || json['explicit'] == 1 ? 1 : 0, 'has_lyrics': json['hasLyrics'] == true || json['hasLyrics'] == 1 ? 1 : 0, + 'has_replaygain': metadataHasReplayGain(json) ? 1 : 0, 'format': json['format'], 'audio_metadata_scan_version': (json['audioMetadataScanVersion'] as num?)?.toInt() ?? - audioMetadataScanVersion, + (json['metadataFromFilename'] == true ? 0 : audioMetadataScanVersion), }; row.addAll( _queueColumns( @@ -777,6 +794,8 @@ class LibraryDatabase { 'copyright': row['copyright'], 'explicit': row['explicit'] == 1 || row['explicit'] == true, 'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true, + 'hasReplayGain': + row['has_replaygain'] == 1 || row['has_replaygain'] == true, 'format': row['format'], }; } @@ -1937,9 +1956,9 @@ class LibraryDatabase { ..['fileModTime'] = stat?.modified?.millisecondsSinceEpoch ..['format'] = normalizedFormat ..['bitrate'] = convertedBitrate - ..['audioMetadataScanVersion'] = convertedBitrate != null - ? audioMetadataScanVersion - : 0; + // Conversion may rewrite gain tags; rescan the resulting file. + ..['hasReplayGain'] = false + ..['audioMetadataScanVersion'] = 0; if (normalizedFormat == 'mp3' || normalizedFormat == 'opus' || @@ -1987,6 +2006,7 @@ class LibraryDatabase { int? bitrate, bool? explicit, bool? hasLyrics, + bool? hasReplayGain, String? format, }) async { final values = {}; @@ -2008,15 +2028,42 @@ class LibraryDatabase { if (hasLyrics != null) { values['has_lyrics'] = hasLyrics ? 1 : 0; } + if (hasReplayGain != null) { + values['has_replaygain'] = hasReplayGain ? 1 : 0; + } final normalizedFormat = normalizeAudioFormatValue(format); if (normalizedFormat != null) { values['format'] = normalizedFormat; } if (values.isEmpty) return; - values['audio_metadata_scan_version'] = audioMetadataScanVersion; final db = await database; - await db.update('library', values, where: 'id = ?', whereArgs: [id]); + if (hasReplayGain == null && hasLyrics == null) { + await db.update('library', values, where: 'id = ?', whereArgs: [id]); + return; + } + // A quality-only update must not confirm absent tags. ReplayGain-only + // writes can advance lyrics-aware rows, while older rows still rescan. + final replayGainOnly = hasReplayGain != null && hasLyrics == null; + final scanVersion = hasReplayGain != null + ? audioMetadataScanVersion + : lyricsMetadataScanVersion; + final versionSql = replayGainOnly + ? 'CASE WHEN COALESCE(audio_metadata_scan_version, 0) >= ? ' + 'THEN MAX(COALESCE(audio_metadata_scan_version, 0), ?) ' + 'ELSE COALESCE(audio_metadata_scan_version, 0) END' + : 'MAX(COALESCE(audio_metadata_scan_version, 0), ?)'; + final columns = values.keys.toList(growable: false); + await db.rawUpdate( + 'UPDATE library SET ${columns.map((column) => '$column = ?').join(', ')}, ' + 'audio_metadata_scan_version = $versionSql WHERE id = ?', + [ + ...columns.map((column) => values[column]), + if (replayGainOnly) lyricsMetadataScanVersion, + scanVersion, + id, + ], + ); } Future delete(String id) async { diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index 6ca3a24f..f481742c 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -40,6 +40,7 @@ class LocalLibraryItem { final String? copyright; final bool explicit; final bool hasLyrics; + final bool hasReplayGain; final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a const LocalLibraryItem({ @@ -69,6 +70,7 @@ class LocalLibraryItem { this.copyright, this.explicit = false, this.hasLyrics = false, + this.hasReplayGain = false, this.format, }); @@ -99,6 +101,7 @@ class LocalLibraryItem { 'copyright': copyright, 'explicit': explicit, 'hasLyrics': hasLyrics, + 'hasReplayGain': hasReplayGain, 'format': format, }; @@ -130,6 +133,8 @@ class LocalLibraryItem { copyright: json['copyright'] as String?, explicit: json['explicit'] == true || json['explicit'] == 1, hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1, + // Scan results carry the raw replaygain_* tags; stored rows the flag. + hasReplayGain: metadataHasReplayGain(json), format: json['format'] as String?, ); @@ -140,6 +145,7 @@ class LocalLibraryItem { int? bitrate, bool? explicit, bool? hasLyrics, + bool? hasReplayGain, String? format, }) { return LocalLibraryItem( @@ -169,6 +175,7 @@ class LocalLibraryItem { copyright: copyright, explicit: explicit ?? this.explicit, hasLyrics: hasLyrics ?? this.hasLyrics, + hasReplayGain: hasReplayGain ?? this.hasReplayGain, format: format ?? this.format, ); } diff --git a/lib/services/library_database_queue_sql.dart b/lib/services/library_database_queue_sql.dart index 9176c507..1da2786c 100644 --- a/lib/services/library_database_queue_sql.dart +++ b/lib/services/library_database_queue_sql.dart @@ -7,6 +7,11 @@ String confirmedMissingLyricsSqlPredicate({ required String lyricsKnownExpr, }) => '($lyricsKnownExpr) AND COALESCE($hasLyricsExpr, 0) = 0'; +String confirmedMissingReplayGainSqlPredicate({ + required String hasReplayGainExpr, + required String replayGainKnownExpr, +}) => '($replayGainKnownExpr) AND COALESCE($hasReplayGainExpr, 0) = 0'; + class _QueueOrderTerm { final String column; final bool descending; @@ -74,6 +79,8 @@ extension _LibraryDbQueueSql on LibraryDatabase { NULL AS file_mod_time, h.bitrate, h.format, + h.has_replaygain, + h.replaygain_metadata_scan_version, h.sort_track, h.sort_artist, h.sort_album, @@ -161,6 +168,9 @@ extension _LibraryDbQueueSql on LibraryDatabase { l.file_mod_time, l.bitrate, l.format, + l.has_replaygain, + CASE WHEN l.audio_metadata_scan_version >= ${LibraryDatabase.audioMetadataScanVersion} + THEN 1 ELSE 0 END AS replaygain_metadata_scan_version, l.track_name_norm AS sort_track, l.artist_name_norm AS sort_artist, l.album_name_norm AS sort_album, @@ -220,6 +230,8 @@ extension _LibraryDbQueueSql on LibraryDatabase { NULL AS file_mod_time, NULL AS bitrate, NULL AS format, + NULL AS has_replaygain, + NULL AS replaygain_metadata_scan_version, NULL AS sort_track, NULL AS sort_artist, NULL AS sort_album, @@ -408,6 +420,9 @@ extension _LibraryDbQueueSql on LibraryDatabase { labelExpr: 'h.label', hasLyricsExpr: 'h.has_lyrics', lyricsKnownExpr: 'COALESCE(h.lyrics_metadata_scan_version, 0) >= 1', + hasReplayGainExpr: 'h.has_replaygain', + replayGainKnownExpr: + 'COALESCE(h.replaygain_metadata_scan_version, 0) >= 1', ); } @@ -456,6 +471,9 @@ extension _LibraryDbQueueSql on LibraryDatabase { labelExpr: 'l.label', hasLyricsExpr: 'l.has_lyrics', lyricsKnownExpr: + 'COALESCE(l.audio_metadata_scan_version, 0) >= ${LibraryDatabase.lyricsMetadataScanVersion}', + hasReplayGainExpr: 'l.has_replaygain', + replayGainKnownExpr: 'COALESCE(l.audio_metadata_scan_version, 0) >= ${LibraryDatabase.audioMetadataScanVersion}', ); } @@ -478,6 +496,8 @@ extension _LibraryDbQueueSql on LibraryDatabase { required String labelExpr, required String hasLyricsExpr, required String lyricsKnownExpr, + required String hasReplayGainExpr, + required String replayGainKnownExpr, }) { final quality = request.quality?.trim().toLowerCase(); if (quality != null && quality.isNotEmpty) { @@ -572,6 +592,15 @@ extension _LibraryDbQueueSql on LibraryDatabase { ), ); break; + case 'missing-replaygain': + // Only files whose tags were read since ReplayGain was indexed. + where.add( + confirmedMissingReplayGainSqlPredicate( + hasReplayGainExpr: hasReplayGainExpr, + replayGainKnownExpr: replayGainKnownExpr, + ), + ); + break; } } @@ -826,6 +855,8 @@ extension _LibraryDbQueueSql on LibraryDatabase { 'label': row['label'], 'copyright': row['copyright'], 'format': row['format'], + 'hasReplayGain': + row['has_replaygain'] == 1 || row['has_replaygain'] == true, }, }; } @@ -860,6 +891,10 @@ extension _LibraryDbQueueSql on LibraryDatabase { 'sampleRate': row['sample_rate'], 'bitrate': row['bitrate'], 'format': row['format'], + 'hasReplayGain': + row['has_replaygain'] == 1 || row['has_replaygain'] == true, + 'replayGainMetadataScanVersion': + row['replaygain_metadata_scan_version'] ?? 0, 'genre': row['genre'], 'composer': row['composer'], 'label': row['label'], diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 79a10dad..d3948946 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -40,6 +40,32 @@ Map normalizeScannedAudioMetadata( return normalized; } +/// Whether scanned or probed tags carry a usable ReplayGain value. Library scans +/// and readFileMetadata both use the snake_case `replaygain_*` keys; persisted +/// rows round-trip through `hasReplayGain`. +bool metadataHasReplayGain(Map metadata) { + final stored = metadata['hasReplayGain']; + if (stored == true || stored == 1) return true; + for (final key in const ['replaygain_track_gain', 'replaygain_album_gain']) { + final value = metadata[key]; + final gain = double.tryParse( + (value?.toString() ?? '') + .replaceFirst(RegExp(r'\s*dB\s*$', caseSensitive: false), '') + .trim(), + ); + if (gain != null && gain.isFinite) return true; + } + return false; +} + +bool replayGainMetadataWasRead(Map metadata) => + metadata['error'] == null && + metadata['metadataFromFilename'] != true && + (metadata['audio_codec'] != null || + metadata['format'] != null || + metadata.containsKey('replaygain_track_gain') || + metadata.containsKey('replaygain_album_gain')); + int? readPositiveBitrateKbps(dynamic value) { final parsed = readPositiveInt(value); if (parsed == null) return null; diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 0c191037..426650d5 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -230,6 +230,99 @@ void main() { }); }); + group('ReplayGain metadata availability', () { + test( + 'recognizes track or album gain including zero, but not peak alone', + () { + for (final value in ['0.00 dB', '-7.25 dB', ' +2.1 DB ', 0, -4.5]) { + expect( + metadataHasReplayGain({'replaygain_track_gain': value}), + isTrue, + ); + expect( + metadataHasReplayGain({'replaygain_album_gain': value}), + isTrue, + ); + } + for (final value in [null, '', 'dB', 'NaN', 'Infinity', 'not a gain']) { + expect( + metadataHasReplayGain({'replaygain_track_gain': value}), + isFalse, + ); + } + expect( + metadataHasReplayGain({'replaygain_track_peak': '0.99'}), + isFalse, + ); + }, + ); + + test('unreadable or filename-only metadata is not a confirmed absence', () { + expect(replayGainMetadataWasRead({}), isFalse); + expect( + replayGainMetadataWasRead({ + 'error': 'permission denied', + 'format': 'FLAC', + }), + isFalse, + ); + expect( + replayGainMetadataWasRead({ + 'metadataFromFilename': true, + 'format': 'FLAC', + }), + isFalse, + ); + expect(replayGainMetadataWasRead({'audio_codec': 'flac'}), isTrue); + expect(replayGainMetadataWasRead({'replaygain_track_gain': ''}), isTrue); + }); + + test('history round-trip preserves known absence after removing gain', () { + final item = DownloadHistoryItem( + id: 'gain', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/track.flac', + service: 'test', + downloadedAt: DateTime.utc(2026), + hasReplayGain: true, + replayGainMetadataScanVersion: 1, + ); + final tagged = DownloadHistoryItem.fromJson(item.toJson()); + expect(tagged.hasReplayGain, isTrue); + final removed = DownloadHistoryItem.fromJson( + tagged.copyWith(hasReplayGain: false).toJson(), + ); + expect(removed.hasReplayGain, isFalse); + expect(removed.replayGainMetadataScanVersion, 1); + final legacy = item.toJson() + ..remove('hasReplayGain') + ..remove('replayGainMetadataScanVersion'); + expect( + DownloadHistoryItem.fromJson(legacy).replayGainMetadataScanVersion, + 0, + ); + }); + + test('local scan flags survive serialization and tag removal', () { + final item = LocalLibraryItem.fromJson({ + 'id': 'local-gain', + 'trackName': 'Track', + 'artistName': 'Artist', + 'albumName': 'Album', + 'filePath': '/music/track.flac', + 'scannedAt': DateTime.utc(2026).toIso8601String(), + 'replaygain_album_gain': '-6.2 dB', + 'hasLyrics': true, + }); + expect(LocalLibraryItem.fromJson(item.toJson()).hasReplayGain, isTrue); + final removed = item.withAudioMetadata(hasReplayGain: false); + expect(removed.hasReplayGain, isFalse); + expect(removed.hasLyrics, isTrue); + }); + }); + group('missing lyrics filter', () { test( 'requires a completed lyrics scan before treating false as missing', diff --git a/test/replaygain_library_filter_test.dart b/test/replaygain_library_filter_test.dart new file mode 100644 index 00000000..ad8b399d --- /dev/null +++ b/test/replaygain_library_filter_test.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_database.dart'; + +String _tableDefinition(String path, String table) { + final source = File(path).readAsStringSync(); + return RegExp( + 'CREATE TABLE $table\\s*\\([\\s\\S]*?\\n\\s*\\)', + ).firstMatch(source)!.group(0)!; +} + +void main() { + test( + 'ReplayGain filter excludes unknown rows and follows tag add/remove in both databases', + () async { + final historyPredicate = confirmedMissingReplayGainSqlPredicate( + hasReplayGainExpr: 'has_replaygain', + replayGainKnownExpr: + 'COALESCE(replaygain_metadata_scan_version, 0) >= 1', + ); + final localPredicate = confirmedMissingReplayGainSqlPredicate( + hasReplayGainExpr: 'has_replaygain', + replayGainKnownExpr: + 'COALESCE(audio_metadata_scan_version, 0) >= ${LibraryDatabase.audioMetadataScanVersion}', + ); + // Use the production table definitions and filter predicates with real + // SQLite. This catches missing schema columns and NULL/default semantics. + final result = await Process.run('python3', [ + '-c', + r''' +import json, sqlite3, sys +data = json.loads(sys.argv[1]) +db = sqlite3.connect(':memory:') +db.execute(data['history']) +db.execute(data['library']) +for table, version, known, predicate in [ + ('history', 'replaygain_metadata_scan_version', 1, data['historyPredicate']), + ('library', 'audio_metadata_scan_version', data['scanVersion'], data['localPredicate']), +]: + for name, gain, scanned in [('unknown', 0, 0), ('legacy', 0, known - 1), + ('tagged', 1, known), ('missing', 0, known)]: + db.execute(f'INSERT INTO {table} (id, track_name, artist_name, album_name, file_path, ' + + ('downloaded_at, service' if table == 'history' else 'scanned_at, source_id') + + f', has_replaygain, {version}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + (name, 'Track', 'Artist', 'Album', '/'+name+'.flac', '2026-01-01', 'test', gain, scanned)) + def matches(): + return [row[0] for row in db.execute(f'SELECT id FROM {table} WHERE {predicate} ORDER BY id')] + assert matches() == ['missing'], (table, matches()) + db.execute(f"UPDATE {table} SET has_replaygain = 1 WHERE id = 'missing'") + assert matches() == [], (table, matches()) + db.execute(f"UPDATE {table} SET has_replaygain = 0 WHERE id = 'tagged'") + assert matches() == ['tagged'], (table, matches()) +print('both databases passed') +''', + jsonEncode({ + 'history': _tableDefinition( + 'lib/services/history_database.dart', + 'history', + ), + 'library': _tableDefinition( + 'lib/services/library_database.dart', + 'library', + ), + 'historyPredicate': historyPredicate, + 'localPredicate': localPredicate, + 'scanVersion': LibraryDatabase.audioMetadataScanVersion, + }), + ]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(result.stdout, contains('both databases passed')); + }, + ); +} diff --git a/test/track_metadata_replaygain_test.dart b/test/track_metadata_replaygain_test.dart index f454ea9b..83b7a531 100644 --- a/test/track_metadata_replaygain_test.dart +++ b/test/track_metadata_replaygain_test.dart @@ -10,6 +10,44 @@ import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +class _History extends DownloadHistoryNotifier { + _History(this._item); + final DownloadHistoryItem _item; + + @override + DownloadHistoryState build() => DownloadHistoryState(items: [_item]); + + @override + Future updateAudioMetadataForItem({ + required String id, + String? quality, + int? bitDepth, + int? sampleRate, + int? bitrate, + String? format, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + int? duration, + String? composer, + bool? explicit, + bool? hasLyrics, + int? lyricsMetadataScanVersion, + bool? hasReplayGain, + int? replayGainMetadataScanVersion, + }) async { + state = state.copyWith( + items: [ + state.items.single.copyWith( + hasReplayGain: hasReplayGain, + replayGainMetadataScanVersion: replayGainMetadataScanVersion, + ), + ], + ); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel('com.zarz.spotiflac/backend'); @@ -71,6 +109,9 @@ void main() { ); await tester.pumpWidget( ProviderScope( + overrides: [ + downloadHistoryProvider.overrideWith(() => _History(item)), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -79,6 +120,13 @@ void main() { ), ); await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(TrackMetadataScreen)), + ); + expect( + container.read(downloadHistoryProvider).items.single.hasReplayGain, + hasTags, + ); for (final text in [ 'ReplayGain Track Gain', 'ReplayGain Track Peak', @@ -121,6 +169,9 @@ void main() { expect(find.text('ReplayGain tags removed'), findsOneWidget); expect(find.text('ReplayGain Track Gain'), findsNothing); expect(find.text('ReplayGain Album Gain'), findsNothing); + final updated = container.read(downloadHistoryProvider).items.single; + expect(updated.hasReplayGain, isFalse); + expect(updated.replayGainMetadataScanVersion, 1); expect(tester.takeException(), isNull); }); }