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

+93
View File
@@ -230,6 +230,99 @@ void main() {
});
});
group('ReplayGain metadata availability', () {
test(
'recognizes track or album gain including zero, but not peak alone',
() {
for (final value in ['0.00 dB', '-7.25 dB', ' +2.1 DB ', 0, -4.5]) {
expect(
metadataHasReplayGain({'replaygain_track_gain': value}),
isTrue,
);
expect(
metadataHasReplayGain({'replaygain_album_gain': value}),
isTrue,
);
}
for (final value in [null, '', 'dB', 'NaN', 'Infinity', 'not a gain']) {
expect(
metadataHasReplayGain({'replaygain_track_gain': value}),
isFalse,
);
}
expect(
metadataHasReplayGain({'replaygain_track_peak': '0.99'}),
isFalse,
);
},
);
test('unreadable or filename-only metadata is not a confirmed absence', () {
expect(replayGainMetadataWasRead({}), isFalse);
expect(
replayGainMetadataWasRead({
'error': 'permission denied',
'format': 'FLAC',
}),
isFalse,
);
expect(
replayGainMetadataWasRead({
'metadataFromFilename': true,
'format': 'FLAC',
}),
isFalse,
);
expect(replayGainMetadataWasRead({'audio_codec': 'flac'}), isTrue);
expect(replayGainMetadataWasRead({'replaygain_track_gain': ''}), isTrue);
});
test('history round-trip preserves known absence after removing gain', () {
final item = DownloadHistoryItem(
id: 'gain',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: '/music/track.flac',
service: 'test',
downloadedAt: DateTime.utc(2026),
hasReplayGain: true,
replayGainMetadataScanVersion: 1,
);
final tagged = DownloadHistoryItem.fromJson(item.toJson());
expect(tagged.hasReplayGain, isTrue);
final removed = DownloadHistoryItem.fromJson(
tagged.copyWith(hasReplayGain: false).toJson(),
);
expect(removed.hasReplayGain, isFalse);
expect(removed.replayGainMetadataScanVersion, 1);
final legacy = item.toJson()
..remove('hasReplayGain')
..remove('replayGainMetadataScanVersion');
expect(
DownloadHistoryItem.fromJson(legacy).replayGainMetadataScanVersion,
0,
);
});
test('local scan flags survive serialization and tag removal', () {
final item = LocalLibraryItem.fromJson({
'id': 'local-gain',
'trackName': 'Track',
'artistName': 'Artist',
'albumName': 'Album',
'filePath': '/music/track.flac',
'scannedAt': DateTime.utc(2026).toIso8601String(),
'replaygain_album_gain': '-6.2 dB',
'hasLyrics': true,
});
expect(LocalLibraryItem.fromJson(item.toJson()).hasReplayGain, isTrue);
final removed = item.withAudioMetadata(hasReplayGain: false);
expect(removed.hasReplayGain, isFalse);
expect(removed.hasLyrics, isTrue);
});
});
group('missing lyrics filter', () {
test(
'requires a completed lyrics scan before treating false as missing',
+75
View File
@@ -0,0 +1,75 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/library_database.dart';
String _tableDefinition(String path, String table) {
final source = File(path).readAsStringSync();
return RegExp(
'CREATE TABLE $table\\s*\\([\\s\\S]*?\\n\\s*\\)',
).firstMatch(source)!.group(0)!;
}
void main() {
test(
'ReplayGain filter excludes unknown rows and follows tag add/remove in both databases',
() async {
final historyPredicate = confirmedMissingReplayGainSqlPredicate(
hasReplayGainExpr: 'has_replaygain',
replayGainKnownExpr:
'COALESCE(replaygain_metadata_scan_version, 0) >= 1',
);
final localPredicate = confirmedMissingReplayGainSqlPredicate(
hasReplayGainExpr: 'has_replaygain',
replayGainKnownExpr:
'COALESCE(audio_metadata_scan_version, 0) >= ${LibraryDatabase.audioMetadataScanVersion}',
);
// Use the production table definitions and filter predicates with real
// SQLite. This catches missing schema columns and NULL/default semantics.
final result = await Process.run('python3', [
'-c',
r'''
import json, sqlite3, sys
data = json.loads(sys.argv[1])
db = sqlite3.connect(':memory:')
db.execute(data['history'])
db.execute(data['library'])
for table, version, known, predicate in [
('history', 'replaygain_metadata_scan_version', 1, data['historyPredicate']),
('library', 'audio_metadata_scan_version', data['scanVersion'], data['localPredicate']),
]:
for name, gain, scanned in [('unknown', 0, 0), ('legacy', 0, known - 1),
('tagged', 1, known), ('missing', 0, known)]:
db.execute(f'INSERT INTO {table} (id, track_name, artist_name, album_name, file_path, '
+ ('downloaded_at, service' if table == 'history' else 'scanned_at, source_id')
+ f', has_replaygain, {version}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
(name, 'Track', 'Artist', 'Album', '/'+name+'.flac', '2026-01-01', 'test', gain, scanned))
def matches():
return [row[0] for row in db.execute(f'SELECT id FROM {table} WHERE {predicate} ORDER BY id')]
assert matches() == ['missing'], (table, matches())
db.execute(f"UPDATE {table} SET has_replaygain = 1 WHERE id = 'missing'")
assert matches() == [], (table, matches())
db.execute(f"UPDATE {table} SET has_replaygain = 0 WHERE id = 'tagged'")
assert matches() == ['tagged'], (table, matches())
print('both databases passed')
''',
jsonEncode({
'history': _tableDefinition(
'lib/services/history_database.dart',
'history',
),
'library': _tableDefinition(
'lib/services/library_database.dart',
'library',
),
'historyPredicate': historyPredicate,
'localPredicate': localPredicate,
'scanVersion': LibraryDatabase.audioMetadataScanVersion,
}),
]);
expect(result.exitCode, 0, reason: '${result.stderr}');
expect(result.stdout, contains('both databases passed'));
},
);
}
+51
View File
@@ -10,6 +10,44 @@ import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
class _History extends DownloadHistoryNotifier {
_History(this._item);
final DownloadHistoryItem _item;
@override
DownloadHistoryState build() => DownloadHistoryState(items: [_item]);
@override
Future<void> updateAudioMetadataForItem({
required String id,
String? quality,
int? bitDepth,
int? sampleRate,
int? bitrate,
String? format,
int? trackNumber,
int? totalTracks,
int? discNumber,
int? totalDiscs,
int? duration,
String? composer,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
bool? hasReplayGain,
int? replayGainMetadataScanVersion,
}) async {
state = state.copyWith(
items: [
state.items.single.copyWith(
hasReplayGain: hasReplayGain,
replayGainMetadataScanVersion: replayGainMetadataScanVersion,
),
],
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
@@ -71,6 +109,9 @@ void main() {
);
await tester.pumpWidget(
ProviderScope(
overrides: [
downloadHistoryProvider.overrideWith(() => _History(item)),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
@@ -79,6 +120,13 @@ void main() {
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(TrackMetadataScreen)),
);
expect(
container.read(downloadHistoryProvider).items.single.hasReplayGain,
hasTags,
);
for (final text in [
'ReplayGain Track Gain',
'ReplayGain Track Peak',
@@ -121,6 +169,9 @@ void main() {
expect(find.text('ReplayGain tags removed'), findsOneWidget);
expect(find.text('ReplayGain Track Gain'), findsNothing);
expect(find.text('ReplayGain Album Gain'), findsNothing);
final updated = container.read(downloadHistoryProvider).items.single;
expect(updated.hasReplayGain, isFalse);
expect(updated.replayGainMetadataScanVersion, 1);
expect(tester.takeException(), isNull);
});
}