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

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