mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-03 14:46:57 +02:00
feat(library): filter tracks missing verified ReplayGain tags
This commit is contained in:
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user