mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
fix(library): stage native scans before owner SQLite publication
This commit is contained in:
1 parent
d285f26f9a
commit
8ca9205cc4
7 files changed
+1784
-53
No files matched your search
@@ -0,0 +1,451 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_native_adapter.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
import '../test/support/library_native_fixture.dart';
|
||||
|
||||
Future<File> _wav(String path) async {
|
||||
final bytes = Uint8List(44 + 320);
|
||||
final data = ByteData.sublistView(bytes);
|
||||
void text(int offset, String value) =>
|
||||
bytes.setRange(offset, offset + value.length, value.codeUnits);
|
||||
text(0, 'RIFF');
|
||||
data.setUint32(4, bytes.length - 8, Endian.little);
|
||||
text(8, 'WAVE');
|
||||
text(12, 'fmt ');
|
||||
data.setUint32(16, 16, Endian.little);
|
||||
data.setUint16(20, 1, Endian.little);
|
||||
data.setUint16(22, 1, Endian.little);
|
||||
data.setUint32(24, 8000, Endian.little);
|
||||
data.setUint32(28, 16000, Endian.little);
|
||||
data.setUint16(32, 2, Endian.little);
|
||||
data.setUint16(34, 16, Endian.little);
|
||||
text(36, 'data');
|
||||
data.setUint32(40, bytes.length - 44, Endian.little);
|
||||
return File(path).writeAsBytes(bytes, flush: true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
late Directory root;
|
||||
late Database database;
|
||||
late LibraryNativeAdapter adapter;
|
||||
var initialized = false;
|
||||
final privatePaths = <String>[];
|
||||
Future<void> Function(Map<String, dynamic>, Map<String, dynamic>)? afterStage;
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('native-library-private-');
|
||||
database = await openDatabase(
|
||||
'${root.path}/live.db',
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode=WAL');
|
||||
await db.execute('PRAGMA recursive_triggers=ON');
|
||||
},
|
||||
);
|
||||
await createNativeLibraryFixture(
|
||||
database,
|
||||
historyPath: '${root.path}/history.db',
|
||||
);
|
||||
privatePaths.clear();
|
||||
afterStage = null;
|
||||
adapter = LibraryNativeAdapter(
|
||||
database,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: (request, {requestId}) async {
|
||||
final private = request['library_path'] as String;
|
||||
expect(private, isNot(database.path));
|
||||
final attachments = await database.rawQuery('PRAGMA database_list');
|
||||
expect(
|
||||
attachments.where(
|
||||
(row) =>
|
||||
(row['name'] as String).startsWith('library_private_') ||
|
||||
(row['name'] as String).startsWith('native_snapshot_'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(request['history_path'], isNot('${root.path}/history.db'));
|
||||
privatePaths.add(private);
|
||||
final result = await PlatformBridge.runNativeDataJob(
|
||||
request,
|
||||
requestId: requestId,
|
||||
);
|
||||
await afterStage?.call(request, result);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
await addNativeLibraryTrack(database, nativeLibraryTrack('old'));
|
||||
await addNativeLibraryTrack(
|
||||
database,
|
||||
nativeLibraryTrack('other', path: '/other/other.flac'),
|
||||
source: 'other',
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
if (initialized) {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
initialized = false;
|
||||
}
|
||||
await database.close();
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
Future<LibraryScanNDJSONFile> scan(
|
||||
List<Map<String, dynamic>> rows, {
|
||||
int errors = 0,
|
||||
}) async {
|
||||
final file = File('${root.path}/scan.ndjson');
|
||||
await file.writeAsString(rows.map(jsonEncode).join('\n'));
|
||||
return LibraryScanNDJSONFile(
|
||||
file: file,
|
||||
expectedCount: rows.length,
|
||||
errorCount: errors,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cleanAndHealthy() async {
|
||||
for (final path in privatePaths) {
|
||||
expect(await File(path).parent.exists(), isFalse);
|
||||
}
|
||||
expect(await database.rawQuery('PRAGMA integrity_check'), [
|
||||
{'integrity_check': 'ok'},
|
||||
]);
|
||||
expect(
|
||||
await database.rawQuery(
|
||||
'SELECT item_id FROM library_path_keys WHERE item_id NOT IN(SELECT id FROM library)',
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
final lookups = await database.rawQuery(
|
||||
'SELECT source_id, kind, value, ref_count FROM library_lookup_summary ORDER BY source_id,kind,value',
|
||||
);
|
||||
final expected = await database.rawQuery(
|
||||
"SELECT source_id, 'match' AS kind, match_key AS value, COUNT(*) AS ref_count FROM library WHERE match_key IS NOT NULL AND match_key != '' GROUP BY source_id, match_key ORDER BY source_id,kind,value",
|
||||
);
|
||||
expect(lookups, expected);
|
||||
if ((await database.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE name='library_search_fts'",
|
||||
)).isNotEmpty) {
|
||||
expect(
|
||||
await database.rawQuery(
|
||||
"SELECT rowid FROM library_search_fts WHERE library_search_fts MATCH 'track' ORDER BY rowid",
|
||||
),
|
||||
await database.rawQuery(
|
||||
"SELECT rowid FROM library WHERE search_text LIKE '%track%' ORDER BY rowid",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'production full scan repeatedly publishes exact rows, empty and partial states through owner SQLite',
|
||||
(tester) async {
|
||||
final timings = <int>[];
|
||||
for (final count in [2, 0, 3, 0]) {
|
||||
final rows = [
|
||||
for (var i = 0; i < count; i++) nativeLibraryTrack('scan-$i'),
|
||||
];
|
||||
afterStage = count == 3
|
||||
? (_, _) => addNativeLibraryDownload(database, '/music/scan-2.flac')
|
||||
: null;
|
||||
final input = await scan(rows);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await adapter.importScanFile(
|
||||
'source',
|
||||
input,
|
||||
requestId: 'full-$count-${timings.length}',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
timings.add(stopwatch.elapsedMicroseconds);
|
||||
final accepted = count == 3 ? rows.take(2).toList() : rows;
|
||||
expect(result['committed'], isTrue);
|
||||
expect(result['durable'], isTrue);
|
||||
expect(result['inserted'], accepted.length);
|
||||
expect(result['skipped'], count - accepted.length);
|
||||
expect(
|
||||
await database.query(
|
||||
'library',
|
||||
where: 'source_id=?',
|
||||
whereArgs: ['source'],
|
||||
orderBy: 'id',
|
||||
),
|
||||
accepted
|
||||
.map((row) => LibraryRowMapper.encode(row, sourceId: 'source'))
|
||||
.toList(),
|
||||
);
|
||||
await cleanAndHealthy();
|
||||
}
|
||||
await addNativeLibraryTrack(database, nativeLibraryTrack('unreadable'));
|
||||
await adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')], errors: 1),
|
||||
requestId: 'partial',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(
|
||||
(await database.query(
|
||||
'library',
|
||||
orderBy: 'id',
|
||||
)).map((row) => row['id']),
|
||||
['new', 'other', 'unreadable'],
|
||||
);
|
||||
await cleanAndHealthy();
|
||||
binding.reportData ??= {};
|
||||
binding.reportData!['library_private_full'] = {
|
||||
'samples_us': timings,
|
||||
'parity': true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'source edit, late SQL failure and cancellation after native staging leave live rows unchanged',
|
||||
(tester) async {
|
||||
final before = await nativeLibraryDigest(database);
|
||||
var cancelled = false;
|
||||
afterStage = (_, _) async {
|
||||
cancelled = true;
|
||||
};
|
||||
await expectLater(
|
||||
adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'cancel-staged',
|
||||
isCancelled: () => cancelled,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await nativeLibraryDigest(database), before);
|
||||
afterStage = (_, _) => database
|
||||
.update(
|
||||
'library_sources',
|
||||
{'enabled': 0},
|
||||
where: 'id=?',
|
||||
whereArgs: ['source'],
|
||||
)
|
||||
.then((_) {});
|
||||
await expectLater(
|
||||
adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'source-changed',
|
||||
isCancelled: () => false,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await nativeLibraryDigest(database), before);
|
||||
await database.update(
|
||||
'library_sources',
|
||||
{'enabled': 1},
|
||||
where: 'id=?',
|
||||
whereArgs: ['source'],
|
||||
);
|
||||
afterStage = null;
|
||||
await database.execute(
|
||||
"CREATE TRIGGER reject_new BEFORE INSERT ON library WHEN new.id='new' BEGIN SELECT RAISE(ABORT,'fixture late failure'); END",
|
||||
);
|
||||
await expectLater(
|
||||
adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'owner-rollback',
|
||||
isCancelled: () => false,
|
||||
),
|
||||
throwsA(anything),
|
||||
);
|
||||
expect(await nativeLibraryDigest(database), before);
|
||||
await cleanAndHealthy();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'production timestamp export uses detached projection and conditionally backfills current owner rows',
|
||||
(tester) async {
|
||||
final zero = await _wav('${root.path}/zero.wav');
|
||||
final changed = await _wav('${root.path}/changed.wav');
|
||||
final legacy = await _wav('${root.path}/legacy.wav');
|
||||
for (final file in [zero, changed, legacy]) {
|
||||
await addNativeLibraryTrack(
|
||||
database,
|
||||
nativeLibraryTrack(
|
||||
file.uri.pathSegments.last,
|
||||
path: file.path,
|
||||
timestamp: 0,
|
||||
version: file == legacy ? 3 : 4,
|
||||
),
|
||||
);
|
||||
}
|
||||
afterStage = (_, _) async {
|
||||
await database.update(
|
||||
'library',
|
||||
{'file_mod_time': 777, 'sort_added': 777},
|
||||
where: 'file_path=?',
|
||||
whereArgs: [changed.path],
|
||||
);
|
||||
await database.update(
|
||||
'library',
|
||||
{'file_mod_time': 0},
|
||||
where: 'id=?',
|
||||
whereArgs: ['old'],
|
||||
);
|
||||
};
|
||||
final path = await adapter.writeFileModTimesSnapshot(
|
||||
'source',
|
||||
requestId: 'snapshot',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
final lines = await File(path).readAsLines();
|
||||
expect(lines, contains('-1\t${legacy.path}'));
|
||||
expect(lines, contains('1770000000000\t/music/old.flac'));
|
||||
final zeroTime = int.parse(
|
||||
lines
|
||||
.singleWhere((line) => line.endsWith('\t${zero.path}'))
|
||||
.split('\t')
|
||||
.first,
|
||||
);
|
||||
expect(zeroTime, greaterThan(0));
|
||||
final liveRows = await database.query(
|
||||
'library',
|
||||
columns: ['file_path', 'file_mod_time'],
|
||||
);
|
||||
expect(
|
||||
liveRows.singleWhere(
|
||||
(row) => row['file_path'] == zero.path,
|
||||
)['file_mod_time'],
|
||||
zeroTime,
|
||||
);
|
||||
expect(
|
||||
liveRows.singleWhere(
|
||||
(row) => row['file_path'] == changed.path,
|
||||
)['file_mod_time'],
|
||||
777,
|
||||
);
|
||||
expect(
|
||||
liveRows.singleWhere(
|
||||
(row) => row['file_path'] == legacy.path,
|
||||
)['file_mod_time'],
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
liveRows.singleWhere(
|
||||
(row) => row['file_path'] == '/music/old.flac',
|
||||
)['file_mod_time'],
|
||||
0,
|
||||
);
|
||||
await cleanAndHealthy();
|
||||
await File(path).delete();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'production folder incremental scan retains removals and publishes current History filtering',
|
||||
(tester) async {
|
||||
final folder = await Directory('${root.path}/audio').create();
|
||||
final extensions = await Directory('${root.path}/extensions').create();
|
||||
final data = await Directory('${root.path}/extension-data').create();
|
||||
await PlatformBridge.initExtensionSystem(
|
||||
extensions.path,
|
||||
data.path,
|
||||
masterKey: base64Encode(List.filled(32, 1)),
|
||||
lyricsProviders: const [],
|
||||
lyricsFetchOptions: const {},
|
||||
allowedDirectories: [folder.path],
|
||||
);
|
||||
initialized = true;
|
||||
final unchanged = await _wav('${folder.path}/unchanged.wav');
|
||||
final added = await _wav('${folder.path}/added.wav');
|
||||
final missing = '${folder.path}/missing.wav';
|
||||
await database.update(
|
||||
'library_sources',
|
||||
{'path': folder.path},
|
||||
where: 'id=?',
|
||||
whereArgs: ['source'],
|
||||
);
|
||||
await database.delete(
|
||||
'library_path_keys',
|
||||
where: 'item_id=?',
|
||||
whereArgs: ['old'],
|
||||
);
|
||||
await database.delete('library', where: 'id=?', whereArgs: ['old']);
|
||||
await addNativeLibraryTrack(
|
||||
database,
|
||||
nativeLibraryTrack(
|
||||
'unchanged',
|
||||
path: unchanged.path,
|
||||
timestamp: (await unchanged.stat()).modified.millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
await addNativeLibraryTrack(
|
||||
database,
|
||||
nativeLibraryTrack('missing', path: missing),
|
||||
);
|
||||
final snapshot = await adapter.writeFileModTimesSnapshot(
|
||||
'source',
|
||||
requestId: 'incremental-snapshot',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
List<Map<String, Object?>>? staged;
|
||||
afterStage = (request, _) async {
|
||||
final private = await openDatabase(
|
||||
request['library_path'] as String,
|
||||
singleInstance: false,
|
||||
);
|
||||
try {
|
||||
staged = await private.query('library', orderBy: 'id');
|
||||
expect(
|
||||
await private.query(
|
||||
'native_library_removed_paths',
|
||||
columns: ['path'],
|
||||
orderBy: 'ordinal',
|
||||
),
|
||||
[
|
||||
{'path': missing},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await private.close();
|
||||
}
|
||||
await addNativeLibraryDownload(database, unchanged.path);
|
||||
};
|
||||
final watch = Stopwatch()..start();
|
||||
final result = await adapter.scanIncremental(
|
||||
'source',
|
||||
folder.path,
|
||||
snapshot,
|
||||
isSaf: false,
|
||||
requestId: 'incremental',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(result['committed'], isTrue);
|
||||
expect(result['durable'], isTrue);
|
||||
expect(result['inserted'], 1);
|
||||
expect(result['scanned_count'], 1);
|
||||
expect(result['skipped'], 1);
|
||||
expect(result['deleted_count'], 1);
|
||||
expect(result['skippedCount'], 1);
|
||||
expect(result['totalFiles'], 2);
|
||||
expect(
|
||||
await database.query(
|
||||
'library',
|
||||
where: 'source_id=?',
|
||||
whereArgs: ['source'],
|
||||
orderBy: 'id',
|
||||
),
|
||||
staged,
|
||||
);
|
||||
expect(staged!.single['file_path'], added.path);
|
||||
await cleanAndHealthy();
|
||||
await File(snapshot).delete();
|
||||
binding.reportData ??= {};
|
||||
binding.reportData!['library_private_incremental'] = {
|
||||
'total_us': watch.elapsedMicroseconds,
|
||||
'parity': true,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_queue_store.dart';
|
||||
import 'package:spotiflac_android/services/library_cleanup.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/services/library_native_adapter.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/library_schema.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -77,28 +78,6 @@ class LibraryDatabase {
|
||||
|
||||
bool get searchFtsAvailable => _searchFtsAvailable ?? false;
|
||||
|
||||
Future<Map<String, dynamic>> _nativeScanRequest(String sourceId) async {
|
||||
final db = await database;
|
||||
final history = await HistoryDatabase.instance.database;
|
||||
final sources = await db.query(
|
||||
'library_sources',
|
||||
columns: ['path', 'enabled'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [sourceId],
|
||||
limit: 1,
|
||||
);
|
||||
if (sources.isEmpty || sources.single['enabled'] != 1) {
|
||||
throw StateError('Library source changed or was removed during scan');
|
||||
}
|
||||
return {
|
||||
'library_path': db.path,
|
||||
'history_path': history.path,
|
||||
'source_id': sourceId,
|
||||
'expected_source_path': sources.single['path'],
|
||||
'platform': Platform.isAndroid ? 'android' : 'ios',
|
||||
};
|
||||
}
|
||||
|
||||
/// Keeps native scan output native through parsing, indexing and the atomic
|
||||
/// source swap. The stream implementation remains the desktop/test adapter.
|
||||
Future<({int inserted, int skipped})> replaceSourceScanFile(
|
||||
@@ -109,15 +88,14 @@ class LibraryDatabase {
|
||||
}) async {
|
||||
if (isCancelled()) throw StateError('Library scan cancelled');
|
||||
if (PlatformBridge.supportsCoreBackend) {
|
||||
final request = await _nativeScanRequest(sourceId);
|
||||
if (isCancelled()) throw StateError('Library scan cancelled');
|
||||
final result = await PlatformBridge.runNativeDataJob({
|
||||
...request,
|
||||
'operation': 'library_full_import',
|
||||
'ndjson_path': scan.file.path,
|
||||
'expected_count': scan.expectedCount,
|
||||
'error_count': scan.errorCount,
|
||||
}, requestId: requestId);
|
||||
final db = await database;
|
||||
await _ensureHistoryAttached(db);
|
||||
final result = await LibraryNativeAdapter(db).importScanFile(
|
||||
sourceId,
|
||||
scan,
|
||||
requestId: requestId,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
return (
|
||||
inserted: (result['inserted'] as num).toInt(),
|
||||
skipped: (result['skipped'] as num).toInt(),
|
||||
@@ -154,19 +132,11 @@ class LibraryDatabase {
|
||||
required String requestId,
|
||||
required bool Function() isCancelled,
|
||||
}) async {
|
||||
final request = await _nativeScanRequest(sourceId);
|
||||
if (isCancelled()) throw StateError('Library scan cancelled');
|
||||
final directory = await getTemporaryDirectory();
|
||||
if (isCancelled()) throw StateError('Library scan cancelled');
|
||||
final result = await PlatformBridge.runNativeDataJob({
|
||||
...request,
|
||||
'operation': 'library_snapshot',
|
||||
'snapshot_path': join(
|
||||
directory.path,
|
||||
'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv',
|
||||
),
|
||||
}, requestId: requestId);
|
||||
return result['path'] as String;
|
||||
return LibraryNativeAdapter(await database).writeFileModTimesSnapshot(
|
||||
sourceId,
|
||||
requestId: requestId,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Native folder scanning and delta ingestion share one operation, so delta
|
||||
@@ -179,14 +149,16 @@ class LibraryDatabase {
|
||||
required String requestId,
|
||||
required bool Function() isCancelled,
|
||||
}) async {
|
||||
final request = await _nativeScanRequest(sourceId);
|
||||
if (isCancelled()) throw StateError('Library scan cancelled');
|
||||
return PlatformBridge.runNativeDataJob({
|
||||
...request,
|
||||
'operation': 'library_scan_incremental',
|
||||
if (isSaf) 'tree_uri': folderPath else 'folder_path': folderPath,
|
||||
'snapshot_path': snapshotPath,
|
||||
}, requestId: requestId);
|
||||
final db = await database;
|
||||
await _ensureHistoryAttached(db);
|
||||
return LibraryNativeAdapter(db).scanIncremental(
|
||||
sourceId,
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
isSaf: isSaf,
|
||||
requestId: requestId,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<LibrarySearchHit>> searchLibrary({
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_native_snapshot.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
typedef NativeLibraryJobRunner =
|
||||
Future<Map<String, dynamic>> Function(
|
||||
Map<String, dynamic> request, {
|
||||
String? requestId,
|
||||
});
|
||||
|
||||
final _log = AppLogger('LibraryNativeAdapter');
|
||||
var _attachmentSequence = 0;
|
||||
|
||||
/// Native SQLite only opens detached private files. Publication uses the live
|
||||
/// database's owning connection, its current History keys and existing indexes.
|
||||
class LibraryNativeAdapter {
|
||||
LibraryNativeAdapter(
|
||||
this.database, {
|
||||
NativeLibraryJobRunner? nativeJobRunner,
|
||||
this.temporaryDirectory,
|
||||
String? platform,
|
||||
}) : _run = nativeJobRunner ?? PlatformBridge.runNativeDataJob,
|
||||
_platform = platform ?? (Platform.isAndroid ? 'android' : 'ios');
|
||||
|
||||
final Database database;
|
||||
final Directory? temporaryDirectory;
|
||||
final NativeLibraryJobRunner _run;
|
||||
final String _platform;
|
||||
final _retainedPaths = <String>{};
|
||||
|
||||
static void _check(bool Function() isCancelled) {
|
||||
if (isCancelled()) throw StateError('Library scan cancelled');
|
||||
}
|
||||
|
||||
Future<String> _sourcePath(DatabaseExecutor db, String sourceId) async {
|
||||
final rows = await db.query(
|
||||
'library_sources',
|
||||
columns: ['path', 'enabled'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [sourceId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty || rows.single['enabled'] != 1) {
|
||||
throw StateError('Library source changed or was removed during scan');
|
||||
}
|
||||
return rows.single['path'] as String;
|
||||
}
|
||||
|
||||
Future<void> _checkSource(
|
||||
DatabaseExecutor db,
|
||||
String sourceId,
|
||||
String path,
|
||||
) async {
|
||||
if (await _sourcePath(db, sourceId) != path) {
|
||||
throw StateError('Library source changed or was removed during scan');
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _attached<T>(
|
||||
String path,
|
||||
Future<T> Function(String alias) action, {
|
||||
bool publication = false,
|
||||
}) async {
|
||||
final alias = 'library_private_${_attachmentSequence++}';
|
||||
await database.execute('ATTACH DATABASE ? AS $alias', [path]);
|
||||
var completed = false;
|
||||
try {
|
||||
final result = await action(alias);
|
||||
completed = true;
|
||||
return result;
|
||||
} finally {
|
||||
// A successful live commit must never be reported as a cancelled or
|
||||
// failed scan merely because temporary-file cleanup failed afterward.
|
||||
try {
|
||||
await database.execute('DETACH DATABASE $alias');
|
||||
} catch (error) {
|
||||
_retainedPaths.add(path);
|
||||
_log.w('Could not detach private Library database: $error');
|
||||
if (!publication || !completed) {
|
||||
throw NativeSqliteSnapshotDetachException(path, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _workingDirectory() async =>
|
||||
(temporaryDirectory ?? await getTemporaryDirectory()).createTemp(
|
||||
'library-native-',
|
||||
);
|
||||
|
||||
Future<void> _cleanup(Directory directory) async {
|
||||
if (_retainedPaths.any((path) => p.isWithin(directory.path, path))) return;
|
||||
// Also covers DETACH errors from the shared schema-cloning helper.
|
||||
try {
|
||||
final attached = await database.rawQuery('PRAGMA database_list');
|
||||
if (attached.any((row) => p.isWithin(directory.path, '${row['file']}'))) {
|
||||
_log.w('Retaining private Library file that is still attached');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
_log.w('Could not verify private Library detachment: $error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
} catch (error) {
|
||||
_log.w('Could not remove private Library staging: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _request(
|
||||
String sourceId,
|
||||
String sourcePath,
|
||||
Directory directory,
|
||||
) => {
|
||||
'library_path': p.join(directory.path, 'local_library.db'),
|
||||
'history_path': p.join(directory.path, 'history.db'),
|
||||
'source_id': sourceId,
|
||||
'expected_source_path': sourcePath,
|
||||
'platform': _platform,
|
||||
};
|
||||
|
||||
Future<void> _prepareImport(
|
||||
String sourceId,
|
||||
String sourcePath,
|
||||
Directory directory,
|
||||
bool Function() isCancelled,
|
||||
) async {
|
||||
await createNativeSqliteSnapshot(
|
||||
database,
|
||||
p.join(directory.path, 'local_library.db'),
|
||||
tables: ['library', 'library_path_keys', 'library_sources'],
|
||||
version: 17,
|
||||
copyRows: false,
|
||||
);
|
||||
_check(isCancelled);
|
||||
await _attached(p.join(directory.path, 'local_library.db'), (alias) async {
|
||||
await database.transaction((txn) async {
|
||||
await _checkSource(txn, sourceId, sourcePath);
|
||||
_check(isCancelled);
|
||||
await txn.execute(
|
||||
'INSERT INTO $alias.library_sources SELECT * FROM main.library_sources '
|
||||
'WHERE id = ?',
|
||||
[sourceId],
|
||||
);
|
||||
});
|
||||
});
|
||||
// Retain every candidate during staging. Downloads can change while native
|
||||
// work runs; the authoritative anti-join belongs to live publication.
|
||||
await _attached(p.join(directory.path, 'history.db'), (alias) async {
|
||||
await database.execute(
|
||||
'CREATE TABLE $alias.history_path_keys('
|
||||
'item_id TEXT NOT NULL, path_key TEXT NOT NULL, '
|
||||
'PRIMARY KEY(item_id, path_key))',
|
||||
);
|
||||
await database.execute('PRAGMA $alias.user_version = 15');
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> importScanFile(
|
||||
String sourceId,
|
||||
LibraryScanNDJSONFile scan, {
|
||||
required String requestId,
|
||||
required bool Function() isCancelled,
|
||||
}) => _import(
|
||||
sourceId,
|
||||
{
|
||||
'operation': 'library_full_import',
|
||||
'ndjson_path': scan.file.path,
|
||||
'expected_count': scan.expectedCount,
|
||||
'error_count': scan.errorCount,
|
||||
},
|
||||
incremental: false,
|
||||
preserveMissing: scan.errorCount > 0,
|
||||
requestId: requestId,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> scanIncremental(
|
||||
String sourceId,
|
||||
String folderPath,
|
||||
String snapshotPath, {
|
||||
required bool isSaf,
|
||||
required String requestId,
|
||||
required bool Function() isCancelled,
|
||||
}) => _import(
|
||||
sourceId,
|
||||
{
|
||||
'operation': 'library_scan_incremental',
|
||||
if (isSaf) 'tree_uri': folderPath else 'folder_path': folderPath,
|
||||
'snapshot_path': snapshotPath,
|
||||
'retain_removed_paths': true,
|
||||
},
|
||||
incremental: true,
|
||||
preserveMissing: true,
|
||||
requestId: requestId,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> _import(
|
||||
String sourceId,
|
||||
Map<String, dynamic> operation, {
|
||||
required bool incremental,
|
||||
required bool preserveMissing,
|
||||
required String requestId,
|
||||
required bool Function() isCancelled,
|
||||
}) async {
|
||||
_check(isCancelled);
|
||||
final sourcePath = await _sourcePath(database, sourceId);
|
||||
final directory = await _workingDirectory();
|
||||
try {
|
||||
await _prepareImport(sourceId, sourcePath, directory, isCancelled);
|
||||
_check(isCancelled);
|
||||
final result = await _run({
|
||||
..._request(sourceId, sourcePath, directory),
|
||||
...operation,
|
||||
}, requestId: requestId);
|
||||
if (result['committed'] != true) {
|
||||
throw StateError('Native Library staging did not complete');
|
||||
}
|
||||
// Native committed:true only describes the private artifact. Cancellation
|
||||
// still prevents publication until the owner's transaction commits.
|
||||
_check(isCancelled);
|
||||
return await _publish(
|
||||
sourceId,
|
||||
sourcePath,
|
||||
p.join(directory.path, 'local_library.db'),
|
||||
result,
|
||||
incremental: incremental,
|
||||
preserveMissing: preserveMissing,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
} on NativeSqliteSnapshotDetachException catch (error) {
|
||||
_retainedPaths.add(error.path);
|
||||
rethrow;
|
||||
} finally {
|
||||
await _cleanup(directory);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _publish(
|
||||
String sourceId,
|
||||
String sourcePath,
|
||||
String privatePath,
|
||||
Map<String, dynamic> nativeResult, {
|
||||
required bool incremental,
|
||||
required bool preserveMissing,
|
||||
required bool Function() isCancelled,
|
||||
}) => _attached(privatePath, (alias) async {
|
||||
return database.transaction((txn) async {
|
||||
_check(isCancelled);
|
||||
await _checkSource(txn, sourceId, sourcePath);
|
||||
final columns = (await txn.rawQuery(
|
||||
'PRAGMA main.table_info(library)',
|
||||
)).map((column) => '"${column['name']}"').join(', ');
|
||||
final historyMatch =
|
||||
'EXISTS(SELECT 1 FROM $alias.library_path_keys sk '
|
||||
'JOIN history_db.history_path_keys hk ON hk.path_key = sk.path_key '
|
||||
'WHERE sk.item_id = s.id)';
|
||||
Future<int> count(String sql, [List<Object?>? args]) async =>
|
||||
((await txn.rawQuery(sql, args)).single.values.single as num).toInt();
|
||||
final staged = await count('SELECT COUNT(*) FROM $alias.library');
|
||||
final skipped = await count(
|
||||
'SELECT COUNT(*) FROM $alias.library s WHERE $historyMatch',
|
||||
);
|
||||
var excludedExisting = 0;
|
||||
var deleted = 0;
|
||||
final selectedIds = '${alias}_selected';
|
||||
await txn.execute('CREATE TEMP TABLE $selectedIds(id TEXT PRIMARY KEY)');
|
||||
Future<int> deleteWhere(String where, List<Object?> args) async {
|
||||
// Some predicates read the path-key table being deleted. Freeze the
|
||||
// selected IDs first so both deletes apply to the same set.
|
||||
await txn.execute('DELETE FROM $selectedIds');
|
||||
await txn.execute(
|
||||
'INSERT INTO $selectedIds SELECT id FROM main.library WHERE $where',
|
||||
args,
|
||||
);
|
||||
await txn.rawDelete(
|
||||
'DELETE FROM main.library_path_keys WHERE item_id IN('
|
||||
'SELECT id FROM $selectedIds)',
|
||||
);
|
||||
return txn.rawDelete(
|
||||
'DELETE FROM main.library WHERE id IN(SELECT id FROM $selectedIds)',
|
||||
);
|
||||
}
|
||||
|
||||
if (incremental) {
|
||||
excludedExisting += await deleteWhere(
|
||||
'source_id = ? AND EXISTS(SELECT 1 FROM main.library_path_keys lk '
|
||||
'JOIN history_db.history_path_keys hk ON hk.path_key = lk.path_key '
|
||||
'WHERE lk.item_id = library.id)',
|
||||
[sourceId],
|
||||
);
|
||||
_check(isCancelled);
|
||||
deleted = await deleteWhere(
|
||||
'source_id = ? AND file_path IN('
|
||||
'SELECT path FROM $alias.native_library_removed_paths)',
|
||||
[sourceId],
|
||||
);
|
||||
excludedExisting += await deleteWhere(
|
||||
'source_id = ? AND file_path IN(SELECT s.file_path '
|
||||
'FROM $alias.library s WHERE $historyMatch)',
|
||||
[sourceId],
|
||||
);
|
||||
} else {
|
||||
await deleteWhere(
|
||||
preserveMissing
|
||||
? 'source_id = ? AND (id IN(SELECT id FROM $alias.library) '
|
||||
'OR file_path IN(SELECT file_path FROM $alias.library))'
|
||||
: 'source_id = ?',
|
||||
[sourceId],
|
||||
);
|
||||
}
|
||||
_check(isCancelled);
|
||||
await txn.rawDelete(
|
||||
'DELETE FROM main.library_path_keys WHERE item_id IN('
|
||||
'SELECT s.id FROM $alias.library s WHERE NOT $historyMatch)',
|
||||
);
|
||||
await txn.execute(
|
||||
'INSERT OR REPLACE INTO main.library($columns) '
|
||||
'SELECT $columns FROM $alias.library s WHERE NOT $historyMatch '
|
||||
'ORDER BY s.rowid',
|
||||
);
|
||||
await txn.execute(
|
||||
'INSERT OR IGNORE INTO main.library_path_keys '
|
||||
'SELECT sk.item_id, sk.path_key FROM $alias.library_path_keys sk '
|
||||
'JOIN $alias.library s ON s.id = sk.item_id WHERE NOT $historyMatch',
|
||||
);
|
||||
await txn.execute('DROP TABLE $selectedIds');
|
||||
_check(isCancelled);
|
||||
return {
|
||||
...nativeResult,
|
||||
'committed': true,
|
||||
'durable': true,
|
||||
'inserted': staged - skipped,
|
||||
'upserted': staged - skipped,
|
||||
'skipped': skipped + excludedExisting,
|
||||
'deleted_count': deleted,
|
||||
};
|
||||
}, exclusive: true);
|
||||
}, publication: true);
|
||||
|
||||
Future<String> writeFileModTimesSnapshot(
|
||||
String sourceId, {
|
||||
required String requestId,
|
||||
required bool Function() isCancelled,
|
||||
}) async {
|
||||
_check(isCancelled);
|
||||
final sourcePath = await _sourcePath(database, sourceId);
|
||||
final directory = await _workingDirectory();
|
||||
final output = File('${directory.path}.tsv');
|
||||
var published = false;
|
||||
try {
|
||||
final privatePath = p.join(directory.path, 'local_library.db');
|
||||
await _attached(privatePath, (alias) async {
|
||||
await database.transaction((txn) async {
|
||||
await _checkSource(txn, sourceId, sourcePath);
|
||||
_check(isCancelled);
|
||||
await txn.execute(
|
||||
'CREATE TABLE $alias.library AS SELECT id, source_id, file_path, '
|
||||
'file_mod_time, audio_metadata_scan_version, sort_added '
|
||||
'FROM main.library WHERE source_id = ?',
|
||||
[sourceId],
|
||||
);
|
||||
await txn.execute(
|
||||
'CREATE TABLE $alias.library_sources AS SELECT id, path, enabled '
|
||||
'FROM main.library_sources WHERE id = ?',
|
||||
[sourceId],
|
||||
);
|
||||
await txn.execute(
|
||||
'CREATE TABLE $alias.native_library_timestamp_candidates AS '
|
||||
'SELECT file_path FROM $alias.library '
|
||||
'WHERE COALESCE(file_mod_time, 0) = 0 '
|
||||
'AND audio_metadata_scan_version >= 4',
|
||||
);
|
||||
await txn.execute(
|
||||
'CREATE INDEX $alias.native_library_timestamp_path '
|
||||
'ON native_library_timestamp_candidates(file_path)',
|
||||
);
|
||||
await txn.execute(
|
||||
'CREATE INDEX $alias.native_library_snapshot_path '
|
||||
'ON library(file_path)',
|
||||
);
|
||||
await txn.execute(
|
||||
'CREATE INDEX $alias.native_library_snapshot_id '
|
||||
'ON library(source_id, id)',
|
||||
);
|
||||
await txn.execute('PRAGMA $alias.user_version = 17');
|
||||
});
|
||||
});
|
||||
_check(isCancelled);
|
||||
final result = await _run({
|
||||
..._request(sourceId, sourcePath, directory),
|
||||
'operation': 'library_snapshot',
|
||||
'snapshot_path': output.path,
|
||||
}, requestId: requestId);
|
||||
if (result['committed'] != true || result['path'] != output.path) {
|
||||
throw StateError('Native Library snapshot did not complete');
|
||||
}
|
||||
_check(isCancelled);
|
||||
await _attached(privatePath, (alias) async {
|
||||
await database.transaction((txn) async {
|
||||
_check(isCancelled);
|
||||
await _checkSource(txn, sourceId, sourcePath);
|
||||
final timestamp =
|
||||
'(SELECT s.file_mod_time FROM $alias.library s '
|
||||
'JOIN $alias.native_library_timestamp_candidates c '
|
||||
'ON c.file_path = s.file_path '
|
||||
'WHERE s.file_path = library.file_path AND s.file_mod_time > 0)';
|
||||
await txn.rawUpdate(
|
||||
'UPDATE main.library SET file_mod_time = $timestamp, '
|
||||
'sort_added = $timestamp WHERE source_id = ? '
|
||||
'AND COALESCE(file_mod_time, 0) = 0 '
|
||||
'AND audio_metadata_scan_version >= 4 AND $timestamp IS NOT NULL',
|
||||
[sourceId],
|
||||
);
|
||||
_check(isCancelled);
|
||||
}, exclusive: true);
|
||||
}, publication: true);
|
||||
published = true;
|
||||
return output.path;
|
||||
} on NativeSqliteSnapshotDetachException catch (error) {
|
||||
_retainedPaths.add(error.path);
|
||||
rethrow;
|
||||
} finally {
|
||||
await _cleanup(directory);
|
||||
if (!published && await output.exists()) await output.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,12 +509,28 @@ fn import(request: &Value, delta: Option<&Value>, check: Check<'_>) -> Result<Va
|
||||
let paths = delta["removedUris"]
|
||||
.as_array()
|
||||
.or_else(|| delta["deletedPaths"].as_array());
|
||||
// The platform adapter can delete its spilled delta immediately after
|
||||
// this operation. Retain its exact removal selection in the private
|
||||
// staging database for publication by the live database's owner.
|
||||
let retain_removed = request["retain_removed_paths"] == true;
|
||||
if retain_removed {
|
||||
transaction.execute_batch("CREATE TABLE IF NOT EXISTS main.native_library_removed_paths(ordinal INTEGER PRIMARY KEY,path TEXT NOT NULL); DELETE FROM main.native_library_removed_paths;")
|
||||
.map_err(|e| error("stage removed Library paths", e))?;
|
||||
}
|
||||
if let Some(paths) = paths {
|
||||
for path in paths {
|
||||
for (ordinal, path) in paths.iter().enumerate() {
|
||||
check()?;
|
||||
let path = path
|
||||
.as_str()
|
||||
.ok_or("Deleted Library path must be a string")?;
|
||||
if retain_removed {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO main.native_library_removed_paths VALUES(?,?)",
|
||||
params![ordinal as i64, path],
|
||||
)
|
||||
.map_err(|e| error("retain removed Library path", e))?;
|
||||
}
|
||||
transaction.execute("DELETE FROM library_path_keys WHERE item_id IN(SELECT id FROM library WHERE source_id = ? AND file_path = ?)", params![source,path]).map_err(|e| error("delete removed path keys", e))?;
|
||||
deleted += transaction
|
||||
.execute(
|
||||
|
||||
@@ -109,6 +109,76 @@ fn track(id: &str, path: &str) -> Value {
|
||||
json!({"id":id,"trackName":format!("Track {id}"),"artistName":"Artist","albumName":"Album","albumArtist":null,"filePath":path,"scannedAt":"2026-10-06T12:00:00.000Z","fileModTime":1770000000000_i64,"isrc":"USAAA2600001","hasReplayGain":false,"replaygain_track_gain":"-4.5 dB"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_incremental_manifest_retains_selected_paths_and_order() {
|
||||
let fixture = Fixture::new();
|
||||
let mut request = fixture.request.clone();
|
||||
request["operation"] = "library_incremental_import".into();
|
||||
request["retain_removed_paths"] = true.into();
|
||||
request["delta"] =
|
||||
json!({"files":[],"removedUris":["/z","/a","/z"],"deletedPaths":["/ignored"]});
|
||||
execute(&request, &|| Ok(())).unwrap();
|
||||
let paths = fixture
|
||||
.db
|
||||
.prepare("SELECT path FROM native_library_removed_paths ORDER BY ordinal")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(paths, ["/z", "/a", "/z"]);
|
||||
request["delta"] = json!({"scanned":[],"deletedPaths":["/fallback"]});
|
||||
execute(&request, &|| Ok(())).unwrap();
|
||||
let path: String = fixture
|
||||
.db
|
||||
.query_row("SELECT path FROM native_library_removed_paths", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(path, "/fallback");
|
||||
request["delta"] = json!({"files":[],"removedUris":[]});
|
||||
execute(&request, &|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM native_library_removed_paths",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_private_removal_manifest_rolls_back_rows_and_manifest() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.add("old", "/music/old.flac", "source");
|
||||
let mut request = fixture.request.clone();
|
||||
request["operation"] = "library_incremental_import".into();
|
||||
request["retain_removed_paths"] = true.into();
|
||||
request["delta"] =
|
||||
json!({"files":[track("new","/music/new.flac")],"removedUris":["/music/old.flac",42]});
|
||||
assert!(
|
||||
execute(&request, &|| Ok(()))
|
||||
.unwrap_err()
|
||||
.contains("must be a string")
|
||||
);
|
||||
assert_eq!(fixture.ids(), ["old"]);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name='native_library_removed_paths'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_dart_row_and_path_codec_parity() {
|
||||
let fixture: Value = serde_json::from_str(include_str!(
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_native_adapter.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_native_snapshot.dart';
|
||||
|
||||
import 'support/library_native_fixture.dart';
|
||||
import 'support/sqlite_process_database.dart';
|
||||
|
||||
class _NativeHarness {
|
||||
_NativeHarness(this.live);
|
||||
final Database live;
|
||||
List<Map<String, dynamic>> incremental = [];
|
||||
List<String> removed = [];
|
||||
Future<void> Function()? afterStage;
|
||||
final stagedPaths = <String>[];
|
||||
|
||||
Future<Map<String, dynamic>> run(
|
||||
Map<String, dynamic> request, {
|
||||
String? requestId,
|
||||
}) async {
|
||||
final path = request['library_path'] as String;
|
||||
expect(path, isNot(live.path));
|
||||
expect(
|
||||
(await live.rawQuery('PRAGMA database_list')).map((r) => r['file']),
|
||||
isNot(contains(path)),
|
||||
);
|
||||
stagedPaths.add(path);
|
||||
final private = await SqliteProcessDatabase.open(path: path);
|
||||
final snapshot = request['operation'] == 'library_snapshot';
|
||||
var count = 0;
|
||||
try {
|
||||
if (snapshot) {
|
||||
final indexes = await private.rawQuery('PRAGMA index_list(library)');
|
||||
expect(
|
||||
indexes.any((row) => row['name'] == 'native_library_snapshot_id'),
|
||||
isTrue,
|
||||
);
|
||||
final rows = await private.query('library', orderBy: 'id');
|
||||
final lines = <String>[];
|
||||
for (final row in rows) {
|
||||
var timestamp =
|
||||
(row['audio_metadata_scan_version'] as num).toInt() < 4
|
||||
? -1
|
||||
: (row['file_mod_time'] as num?)?.toInt() ?? 0;
|
||||
if (timestamp == 0) {
|
||||
timestamp = 999;
|
||||
await private.update(
|
||||
'library',
|
||||
{'file_mod_time': timestamp, 'sort_added': timestamp},
|
||||
where: 'id = ?',
|
||||
whereArgs: [row['id']],
|
||||
);
|
||||
}
|
||||
lines.add('$timestamp\t${row['file_path']}');
|
||||
}
|
||||
await File(
|
||||
request['snapshot_path'] as String,
|
||||
).writeAsString('${lines.join('\n')}\n');
|
||||
count = rows.length;
|
||||
} else {
|
||||
expect(await private.query('library'), isEmpty);
|
||||
final history = await SqliteProcessDatabase.open(
|
||||
path: request['history_path'] as String,
|
||||
);
|
||||
try {
|
||||
expect(await history.query('history_path_keys'), isEmpty);
|
||||
} finally {
|
||||
await history.close();
|
||||
}
|
||||
final full = request['operation'] == 'library_full_import';
|
||||
final rows = full
|
||||
? (await File(request['ndjson_path'] as String).readAsLines())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.map(
|
||||
(line) =>
|
||||
Map<String, dynamic>.from(jsonDecode(line) as Map),
|
||||
)
|
||||
.toList()
|
||||
: incremental;
|
||||
for (final row in rows) {
|
||||
await addNativeLibraryTrack(private, row);
|
||||
}
|
||||
count = rows.length;
|
||||
if (!full) {
|
||||
expect(request['retain_removed_paths'], isTrue);
|
||||
await private.execute(
|
||||
'CREATE TABLE native_library_removed_paths(ordinal INTEGER PRIMARY KEY,path TEXT NOT NULL)',
|
||||
);
|
||||
for (var i = 0; i < removed.length; i++) {
|
||||
await private.execute(
|
||||
'INSERT INTO native_library_removed_paths VALUES(?,?)',
|
||||
[i, removed[i]],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await private.close();
|
||||
}
|
||||
await afterStage?.call();
|
||||
return {
|
||||
'committed': true,
|
||||
'inserted': count,
|
||||
'skipped': 0,
|
||||
'scanned_count': count,
|
||||
'totalFiles': count + 3,
|
||||
'skippedCount': 3,
|
||||
if (snapshot) 'path': request['snapshot_path'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SqlProbe implements Database, Transaction {
|
||||
_SqlProbe(this.inner, this.path, this.onSql);
|
||||
final DatabaseExecutor inner;
|
||||
@override
|
||||
final String path;
|
||||
final void Function(String) onSql;
|
||||
@override
|
||||
Future<void> execute(String sql, [List<Object?>? args]) async {
|
||||
if (sql.startsWith('DETACH')) onSql(sql);
|
||||
await inner.execute(sql, args);
|
||||
if (!sql.startsWith('DETACH')) onSql(sql);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> rawQuery(
|
||||
String sql, [
|
||||
List<Object?>? args,
|
||||
]) => inner.rawQuery(sql, args);
|
||||
@override
|
||||
Future<int> rawDelete(String sql, [List<Object?>? args]) async {
|
||||
final result = await inner.rawDelete(sql, args);
|
||||
onSql(sql);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> rawUpdate(String sql, [List<Object?>? args]) async {
|
||||
final result = await inner.rawUpdate(sql, args);
|
||||
onSql(sql);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> query(
|
||||
String table, {
|
||||
bool? distinct,
|
||||
List<String>? columns,
|
||||
String? where,
|
||||
List<Object?>? whereArgs,
|
||||
String? groupBy,
|
||||
String? having,
|
||||
String? orderBy,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) => inner.query(
|
||||
table,
|
||||
distinct: distinct,
|
||||
columns: columns,
|
||||
where: where,
|
||||
whereArgs: whereArgs,
|
||||
groupBy: groupBy,
|
||||
having: having,
|
||||
orderBy: orderBy,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
@override
|
||||
Future<T> transaction<T>(
|
||||
Future<T> Function(Transaction) action, {
|
||||
bool? exclusive,
|
||||
}) => (inner as Database).transaction(
|
||||
(txn) => action(_SqlProbe(txn, path, onSql)),
|
||||
exclusive: exclusive,
|
||||
);
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
late SqliteProcessDatabase live;
|
||||
late _NativeHarness native;
|
||||
late LibraryNativeAdapter adapter;
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('library-private-adapter-');
|
||||
live = await SqliteProcessDatabase.open(path: '${root.path}/live.db');
|
||||
await createNativeLibraryFixture(live);
|
||||
native = _NativeHarness(live);
|
||||
adapter = LibraryNativeAdapter(
|
||||
live,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
platform: 'ios',
|
||||
);
|
||||
await addNativeLibraryTrack(live, nativeLibraryTrack('old'));
|
||||
await addNativeLibraryTrack(
|
||||
live,
|
||||
nativeLibraryTrack('other', path: '/other/other.flac'),
|
||||
source: 'other',
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
await live.close();
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
Future<LibraryScanNDJSONFile> scan(
|
||||
List<Map<String, dynamic>> rows, {
|
||||
int errors = 0,
|
||||
}) async {
|
||||
final file = File('${root.path}/scan.ndjson');
|
||||
await file.writeAsString(rows.map(jsonEncode).join('\n'));
|
||||
return LibraryScanNDJSONFile(
|
||||
file: file,
|
||||
expectedCount: rows.length,
|
||||
errorCount: errors,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cleaned() async {
|
||||
for (final path in native.stagedPaths) {
|
||||
expect(await Directory(File(path).parent.path).exists(), isFalse);
|
||||
}
|
||||
expect(
|
||||
(await live.rawQuery(
|
||||
'PRAGMA database_list',
|
||||
)).map((r) => r['name']).where((name) => name != 'temp'),
|
||||
['main', 'history_db'],
|
||||
);
|
||||
expect(await live.rawQuery('PRAGMA integrity_check'), [
|
||||
{'integrity_check': 'ok'},
|
||||
]);
|
||||
}
|
||||
|
||||
test(
|
||||
'full private staging publishes exact rows and filters current History through live triggers',
|
||||
() async {
|
||||
final rows = [nativeLibraryTrack('new'), nativeLibraryTrack('download')];
|
||||
native.afterStage = () =>
|
||||
addNativeLibraryDownload(live, '/music/download.flac');
|
||||
final result = await adapter.importScanFile(
|
||||
'source',
|
||||
await scan(rows),
|
||||
requestId: 'full',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(result, containsPair('durable', true));
|
||||
expect(result['inserted'], 1);
|
||||
expect(result['skipped'], 1);
|
||||
expect(
|
||||
await live.query(
|
||||
'library',
|
||||
where: 'source_id = ?',
|
||||
whereArgs: ['source'],
|
||||
),
|
||||
[LibraryRowMapper.encode(rows.first, sourceId: 'source')],
|
||||
);
|
||||
expect(
|
||||
(await live.query(
|
||||
'library_path_keys',
|
||||
)).map((r) => r['item_id']).toSet(),
|
||||
{'new', 'other'},
|
||||
);
|
||||
final lookup = await live.rawQuery(
|
||||
"SELECT ref_count FROM library_lookup_summary WHERE source_id='source' AND kind='match'",
|
||||
);
|
||||
expect(lookup, [
|
||||
{'ref_count': 1},
|
||||
]);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'partial and empty scans preserve missing rows only for partial scans',
|
||||
() async {
|
||||
await adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')], errors: 1),
|
||||
requestId: 'partial',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [
|
||||
'new',
|
||||
'old',
|
||||
'other',
|
||||
]);
|
||||
await adapter.importScanFile(
|
||||
'source',
|
||||
await scan([], errors: 1),
|
||||
requestId: 'empty-partial',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [
|
||||
'new',
|
||||
'old',
|
||||
'other',
|
||||
]);
|
||||
final result = await adapter.importScanFile(
|
||||
'source',
|
||||
await scan([]),
|
||||
requestId: 'empty',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(result['inserted'], 0);
|
||||
expect((await live.query('library')).map((r) => r['id']), ['other']);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'incremental removes current downloads and exact source-scoped deleted paths',
|
||||
() async {
|
||||
await addNativeLibraryTrack(live, nativeLibraryTrack('unchanged'));
|
||||
await addNativeLibraryTrack(live, nativeLibraryTrack('unreadable'));
|
||||
native.incremental = [
|
||||
nativeLibraryTrack('new'),
|
||||
nativeLibraryTrack('download'),
|
||||
];
|
||||
native.removed = [
|
||||
'/music/old.flac',
|
||||
'/music/old.flac',
|
||||
'/other/other.flac',
|
||||
];
|
||||
native.afterStage = () async {
|
||||
await addNativeLibraryDownload(live, '/music/unchanged.flac');
|
||||
await addNativeLibraryDownload(live, '/music/download.flac');
|
||||
};
|
||||
final result = await adapter.scanIncremental(
|
||||
'source',
|
||||
'/music',
|
||||
'${root.path}/snapshot.tsv',
|
||||
isSaf: false,
|
||||
requestId: 'incremental',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(result['inserted'], 1);
|
||||
expect(result['skipped'], 2);
|
||||
expect(result['deleted_count'], 1);
|
||||
expect(result['skippedCount'], 3);
|
||||
expect(result['scanned_count'], 2);
|
||||
expect(result['totalFiles'], 5);
|
||||
expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [
|
||||
'new',
|
||||
'other',
|
||||
'unreadable',
|
||||
]);
|
||||
expect(
|
||||
(await live.query(
|
||||
'library_path_keys',
|
||||
)).map((r) => r['item_id']).toSet(),
|
||||
{'new', 'other', 'unreadable'},
|
||||
);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'source edit and cancellation after private commit never publish',
|
||||
() async {
|
||||
final before = await nativeLibraryDigest(live);
|
||||
var cancelled = false;
|
||||
native.afterStage = () async {
|
||||
cancelled = true;
|
||||
};
|
||||
await expectLater(
|
||||
adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'cancel',
|
||||
isCancelled: () => cancelled,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await nativeLibraryDigest(live), before);
|
||||
native.afterStage = () => live
|
||||
.update(
|
||||
'library_sources',
|
||||
{'path': '/changed'},
|
||||
where: 'id=?',
|
||||
whereArgs: ['source'],
|
||||
)
|
||||
.then((_) {});
|
||||
await expectLater(
|
||||
adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'source-edit',
|
||||
isCancelled: () => false,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await nativeLibraryDigest(live), before);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'late owner SQL failure rolls back rows, path keys and derived lookups',
|
||||
() async {
|
||||
final before = await nativeLibraryDigest(live);
|
||||
await live.execute(
|
||||
"CREATE TRIGGER reject_new BEFORE INSERT ON library WHEN new.id='new' BEGIN SELECT RAISE(ABORT,'fixture late failure'); END",
|
||||
);
|
||||
await expectLater(
|
||||
adapter.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'rollback',
|
||||
isCancelled: () => false,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await nativeLibraryDigest(live), before);
|
||||
expect(
|
||||
await live.rawQuery(
|
||||
"SELECT name FROM sqlite_temp_master WHERE name LIKE 'library_private_%_selected'",
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancellation during owner publication rolls back accepted rows and keys',
|
||||
() async {
|
||||
var cancelled = false;
|
||||
final before = await nativeLibraryDigest(live);
|
||||
final probed = _SqlProbe(live, live.path, (sql) {
|
||||
if (sql.startsWith('INSERT OR REPLACE INTO main.library(')) {
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
final candidate = LibraryNativeAdapter(
|
||||
probed,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
);
|
||||
await expectLater(
|
||||
candidate.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'cancel-publication',
|
||||
isCancelled: () => cancelled,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await nativeLibraryDigest(live), before);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed preparation DETACH retains file and never invokes native',
|
||||
() async {
|
||||
final probed = _SqlProbe(live, live.path, (sql) {
|
||||
if (sql.startsWith('DETACH')) {
|
||||
throw StateError('fixture detach failure');
|
||||
}
|
||||
});
|
||||
final candidate = LibraryNativeAdapter(
|
||||
probed,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
);
|
||||
await expectLater(
|
||||
candidate.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'detach-prepare',
|
||||
isCancelled: () => false,
|
||||
),
|
||||
throwsA(isA<NativeSqliteSnapshotDetachException>()),
|
||||
);
|
||||
expect(native.stagedPaths, isEmpty);
|
||||
final attached = await live.rawQuery('PRAGMA database_list');
|
||||
final stage = attached.singleWhere(
|
||||
(row) => (row['name'] as String).startsWith('native_snapshot_'),
|
||||
);
|
||||
expect(await File(stage['file'] as String).exists(), isTrue);
|
||||
await live.execute('DETACH DATABASE "${stage['name']}"');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancellation after durable commit still returns the committed result',
|
||||
() async {
|
||||
var publishing = false;
|
||||
var cancelled = false;
|
||||
final probed = _SqlProbe(live, live.path, (sql) {
|
||||
if (sql.startsWith('INSERT OR REPLACE INTO main.library(')) {
|
||||
publishing = true;
|
||||
}
|
||||
if (publishing && sql.startsWith('DETACH')) {
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
final candidate = LibraryNativeAdapter(
|
||||
probed,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
);
|
||||
final result = await candidate.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'cancel-after-commit',
|
||||
isCancelled: () => cancelled,
|
||||
);
|
||||
expect(cancelled, isTrue);
|
||||
expect(result['durable'], isTrue);
|
||||
expect(
|
||||
(await live.query('library', orderBy: 'id')).map((row) => row['id']),
|
||||
['new', 'other'],
|
||||
);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed DETACH after durable publication retains stage and reports success',
|
||||
() async {
|
||||
var published = false;
|
||||
final probed = _SqlProbe(live, live.path, (sql) {
|
||||
if (sql.startsWith('INSERT OR REPLACE INTO main.library(')) {
|
||||
published = true;
|
||||
}
|
||||
if (published && sql.startsWith('DETACH')) {
|
||||
throw StateError('fixture postcommit detach failure');
|
||||
}
|
||||
});
|
||||
final candidate = LibraryNativeAdapter(
|
||||
probed,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
);
|
||||
final result = await candidate.importScanFile(
|
||||
'source',
|
||||
await scan([nativeLibraryTrack('new')]),
|
||||
requestId: 'detach-publish',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(result['durable'], isTrue);
|
||||
expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [
|
||||
'new',
|
||||
'other',
|
||||
]);
|
||||
expect(await File(native.stagedPaths.single).exists(), isTrue);
|
||||
final stage = (await live.rawQuery('PRAGMA database_list')).singleWhere(
|
||||
(row) => (row['name'] as String).startsWith('library_private_'),
|
||||
);
|
||||
await live.execute('DETACH DATABASE "${stage['name']}"');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'timestamp snapshot retains sentinels and only backfills still missing eligible live rows',
|
||||
() async {
|
||||
for (final id in ['zero', 'changed', 'legacy']) {
|
||||
await addNativeLibraryTrack(
|
||||
live,
|
||||
nativeLibraryTrack(id, timestamp: 0, version: id == 'legacy' ? 3 : 4),
|
||||
);
|
||||
}
|
||||
native.afterStage = () async {
|
||||
await live.update(
|
||||
'library',
|
||||
{'file_mod_time': 777, 'sort_added': 777},
|
||||
where: 'id=?',
|
||||
whereArgs: ['changed'],
|
||||
);
|
||||
await live.update(
|
||||
'library',
|
||||
{'file_mod_time': 0},
|
||||
where: 'id=?',
|
||||
whereArgs: ['old'],
|
||||
);
|
||||
};
|
||||
final path = await adapter.writeFileModTimesSnapshot(
|
||||
'source',
|
||||
requestId: 'snapshot',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(await File(path).readAsLines(), [
|
||||
'999\t/music/changed.flac',
|
||||
'-1\t/music/legacy.flac',
|
||||
'1770000000000\t/music/old.flac',
|
||||
'999\t/music/zero.flac',
|
||||
]);
|
||||
final rows = await live.query(
|
||||
'library',
|
||||
columns: ['id', 'file_mod_time'],
|
||||
orderBy: 'id',
|
||||
);
|
||||
expect(rows, [
|
||||
{'id': 'changed', 'file_mod_time': 777},
|
||||
{'id': 'legacy', 'file_mod_time': 0},
|
||||
{'id': 'old', 'file_mod_time': 0},
|
||||
{'id': 'other', 'file_mod_time': 1770000000000},
|
||||
{'id': 'zero', 'file_mod_time': 999},
|
||||
]);
|
||||
await cleaned();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'timestamp preparation DETACH failure retains projection and never invokes native',
|
||||
() async {
|
||||
final probed = _SqlProbe(live, live.path, (sql) {
|
||||
if (sql.startsWith('DETACH')) {
|
||||
throw StateError('fixture timestamp preparation detach failure');
|
||||
}
|
||||
});
|
||||
final candidate = LibraryNativeAdapter(
|
||||
probed,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
);
|
||||
await expectLater(
|
||||
candidate.writeFileModTimesSnapshot(
|
||||
'source',
|
||||
requestId: 'timestamp-detach-prepare',
|
||||
isCancelled: () => false,
|
||||
),
|
||||
throwsA(isA<NativeSqliteSnapshotDetachException>()),
|
||||
);
|
||||
expect(native.stagedPaths, isEmpty);
|
||||
final stage = (await live.rawQuery('PRAGMA database_list')).singleWhere(
|
||||
(row) => (row['name'] as String).startsWith('library_private_'),
|
||||
);
|
||||
expect(await File(stage['file'] as String).exists(), isTrue);
|
||||
await live.execute('DETACH DATABASE "${stage['name']}"');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'timestamp publication DETACH failure retains projection and returns durable snapshot',
|
||||
() async {
|
||||
await addNativeLibraryTrack(
|
||||
live,
|
||||
nativeLibraryTrack('zero', timestamp: 0),
|
||||
);
|
||||
var published = false;
|
||||
final probed = _SqlProbe(live, live.path, (sql) {
|
||||
if (sql.startsWith('UPDATE main.library SET file_mod_time')) {
|
||||
published = true;
|
||||
}
|
||||
if (published && sql.startsWith('DETACH')) {
|
||||
throw StateError('fixture timestamp postcommit detach failure');
|
||||
}
|
||||
});
|
||||
final candidate = LibraryNativeAdapter(
|
||||
probed,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: native.run,
|
||||
);
|
||||
final output = await candidate.writeFileModTimesSnapshot(
|
||||
'source',
|
||||
requestId: 'timestamp-detach-publish',
|
||||
isCancelled: () => false,
|
||||
);
|
||||
expect(await File(output).exists(), isTrue);
|
||||
expect(
|
||||
(await live.query(
|
||||
'library',
|
||||
where: 'id=?',
|
||||
whereArgs: ['zero'],
|
||||
)).single['file_mod_time'],
|
||||
999,
|
||||
);
|
||||
expect(await File(native.stagedPaths.single).exists(), isTrue);
|
||||
final stage = (await live.rawQuery('PRAGMA database_list')).singleWhere(
|
||||
(row) => (row['name'] as String).startsWith('library_private_'),
|
||||
);
|
||||
await live.execute('DETACH DATABASE "${stage['name']}"');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/library_schema.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
|
||||
Map<String, dynamic> nativeLibraryTrack(
|
||||
String id, {
|
||||
String? path,
|
||||
int? timestamp = 1770000000000,
|
||||
int version = 4,
|
||||
}) => {
|
||||
'id': id,
|
||||
'trackName': 'Track $id',
|
||||
'artistName': ' CAFÉ İ 音楽 ',
|
||||
'albumName': 'Album',
|
||||
'albumArtist': null,
|
||||
'filePath': path ?? '/music/$id.flac',
|
||||
'scannedAt': '2026-10-07T12:00:00.000Z',
|
||||
'fileModTime': timestamp,
|
||||
'audioMetadataScanVersion': version,
|
||||
'explicit': true,
|
||||
'hasLyrics': true,
|
||||
'hasReplayGain': true,
|
||||
'bitDepth': 24,
|
||||
'sampleRate': 96000,
|
||||
};
|
||||
|
||||
Future<void> createNativeLibraryFixture(
|
||||
Database db, {
|
||||
String historyPath = ':memory:',
|
||||
}) async {
|
||||
await LibrarySchema.create(db, LibrarySchema.version);
|
||||
await db.execute('PRAGMA user_version = 17');
|
||||
await db.execute('PRAGMA recursive_triggers = ON');
|
||||
await db.execute('ATTACH DATABASE ? AS history_db', [historyPath]);
|
||||
await db.execute(
|
||||
'CREATE TABLE history_db.history_path_keys('
|
||||
'item_id TEXT NOT NULL,path_key TEXT NOT NULL,PRIMARY KEY(item_id,path_key))',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX history_db.history_key_index '
|
||||
'ON history_path_keys(path_key)',
|
||||
);
|
||||
for (final source in ['source', 'other']) {
|
||||
await db.insert('library_sources', {
|
||||
'id': source,
|
||||
'path': source == 'source' ? '/music' : '/other',
|
||||
'display_name': source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addNativeLibraryTrack(
|
||||
DatabaseExecutor db,
|
||||
Map<String, dynamic> track, {
|
||||
String source = 'source',
|
||||
}) async {
|
||||
await db.insert(
|
||||
'library',
|
||||
LibraryRowMapper.encode(track, sourceId: source),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await db.delete(
|
||||
'library_path_keys',
|
||||
where: 'item_id = ?',
|
||||
whereArgs: [track['id']],
|
||||
);
|
||||
final batch = db.batch();
|
||||
sqlite.putPathKeysInBatch(
|
||||
batch,
|
||||
'library_path_keys',
|
||||
track['id'] as String,
|
||||
track['filePath'] as String,
|
||||
);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<void> addNativeLibraryDownload(DatabaseExecutor db, String path) async {
|
||||
final batch = db.batch();
|
||||
sqlite.putPathKeysInBatch(
|
||||
batch,
|
||||
'history_db.history_path_keys',
|
||||
'download-$path',
|
||||
path,
|
||||
);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> nativeLibraryDigest(DatabaseExecutor db) async => {
|
||||
'rows': await db.rawQuery('SELECT * FROM library ORDER BY id'),
|
||||
'keys': await db.rawQuery(
|
||||
'SELECT * FROM library_path_keys ORDER BY item_id,path_key',
|
||||
),
|
||||
'lookup': await db.rawQuery(
|
||||
'SELECT * FROM library_lookup_summary ORDER BY source_id,kind,value',
|
||||
),
|
||||
if ((await db.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE name='library_search_fts'",
|
||||
)).isNotEmpty)
|
||||
'fts': await db.rawQuery(
|
||||
"SELECT rowid FROM library_search_fts WHERE library_search_fts MATCH 'track' ORDER BY rowid",
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user