Files
SpotiFLAC-Mobile/lib/services/library_native_adapter.dart
T

437 lines
15 KiB
Dart

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_helpers.dart' as sqlite;
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 sqlite.transactionWithBusyRetry(database, (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 sqlite.transactionWithBusyRetry(database, (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 sqlite.transactionWithBusyRetry(database, (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 sqlite.transactionWithBusyRetry(database, (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();
}
}
}