mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +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
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user