mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-04 15:16:58 +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
@@ -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