feat(library): filter tracks missing verified ReplayGain tags

This commit is contained in:
zarzet committed 2026-10-02 01:24:26 +07:00
1 parent fbf479c102
commit 718e13f24e
20 files changed
+568 -20

No files matched your search

@@ -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
}
+4
View File
@@ -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"
+4
View File
@@ -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"
@@ -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<String, dynamic> toJson() => {
@@ -108,6 +115,8 @@ class DownloadHistoryItem {
'explicit': explicit,
'hasLyrics': hasLyrics,
'lyricsMetadataScanVersion': lyricsMetadataScanVersion,
'hasReplayGain': hasReplayGain,
'replayGainMetadataScanVersion': replayGainMetadataScanVersion,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> 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,
);
}
}
+17 -1
View File
@@ -214,6 +214,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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<DownloadHistoryState> {
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<DownloadHistoryState> {
explicit: explicit,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
hasReplayGain: hasReplayGain,
replayGainMetadataScanVersion: replayGainMetadataScanVersion,
);
if (updated.quality == current.quality &&
@@ -534,7 +547,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updated.explicit == current.explicit &&
updated.hasLyrics == current.hasLyrics &&
updated.lyricsMetadataScanVersion ==
current.lyricsMetadataScanVersion) {
current.lyricsMetadataScanVersion &&
updated.hasReplayGain == current.hasReplayGain &&
updated.replayGainMetadataScanVersion ==
current.replayGainMetadataScanVersion) {
return;
}
@@ -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<String, dynamic>? 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;
@@ -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<String, dynamic>? 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,
);
}
@@ -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,
);
@@ -1673,6 +1673,9 @@ class _DownloadRun {
copyright: effectiveCopyright,
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
hasReplayGain: lyricsAvailability.hasReplayGain,
replayGainMetadataScanVersion:
lyricsAvailability.replayGainScanVersion,
),
preserveTrackVariant: item.preserveQualityVariant,
);
+11
View File
@@ -1070,6 +1070,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-lyrics',
),
),
AppChoiceChip(
label: Text(
context
.l10n
.libraryFilterMetadataMissingReplayGain,
),
selected: tempMetadata == 'missing-replaygain',
onSelected: (_) => setSheetState(
() => tempMetadata = 'missing-replaygain',
),
),
],
),
const SizedBox(height: 16),
+28
View File
@@ -381,6 +381,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
? 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<TrackMetadataScreen>
needsComposer ||
needsExplicit ||
needsLyricsAvailability ||
needsReplayGainAvailability ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
final localItem = _localLibraryItem;
final localAudioMetadataChanged =
@@ -455,6 +464,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
resolvedBitrate != localItem.bitrate) ||
needsExplicit ||
needsLyricsAvailability ||
needsReplayGainAvailability ||
needsDuration ||
formatChanged);
@@ -534,6 +544,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
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<TrackMetadataScreen>
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<TrackMetadataScreen>
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<TrackMetadataScreen>
bitrate: resolvedBitrate,
explicit: needsExplicit ? resolvedExplicit : null,
hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null,
hasReplayGain: hasResolvedReplayGainValue
? resolvedHasReplayGain
: null,
format: resolvedFormat,
);
});
+31 -2
View File
@@ -542,7 +542,7 @@ Future<void> _performBatchConversion(
/// selection UI around the confirmation dialog; [onConfirmClosed] receives
/// whether the user confirmed.
Future<void> runBatchReplayGain(
BuildContext context,
BuildContext sourceContext,
List<UnifiedLibraryItem> selectedItems, {
required VoidCallback onExitSelectionMode,
bool remove = false,
@@ -551,6 +551,10 @@ Future<void> 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<bool>(
@@ -618,12 +622,37 @@ Future<void> 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);
+28 -3
View File
@@ -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<SharedPreferences> _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> _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<bool> _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,
};
}
+56 -9
View File
@@ -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> _database =
sqlite.SingleFlightInitializer<Database>();
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<void> _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 = <String, dynamic>{};
@@ -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<void> delete(String id) async {
@@ -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,
);
}
@@ -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'],
+26
View File
@@ -40,6 +40,32 @@ Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
+93
View File
@@ -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',
+75
View File
@@ -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'));
},
);
}
+51
View File
@@ -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<void> 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);
});
}