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

250 lines
8.0 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/models/library_collections.dart';
import 'package:spotiflac_android/models/track.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';
// Small selections cost less inline; reserve worker setup for larger lists.
const _workerThreshold = 1024;
const _nativeThreshold = 2048;
var _attachmentSequence = 0;
final _log = AppLogger('CollectionTrackBatch');
class PreparedCollectionTrackBatch {
const PreparedCollectionTrackBatch({
required this.entries,
required this.duplicates,
required this.rows,
this.rowsPath,
this.inputDirectory,
});
final List<CollectionTrackEntry> entries;
final int duplicates;
final List<Map<String, String>> rows;
final String? rowsPath;
final String? inputDirectory;
Future<void> dispose() async {
final directory = inputDirectory;
if (directory != null) await _removeDirectory(directory);
}
}
/// Deduplication and encoding share one worker. Large native batches return a
/// file path instead of transferring thousands of JSON maps back through UI.
Future<PreparedCollectionTrackBatch> prepareCollectionTrackBatch({
required Iterable<Track> tracks,
required Set<String> existingKeys,
required DateTime addedAt,
bool? useNative,
Directory? temporaryDirectory,
}) async {
final snapshot = tracks.toList(growable: false);
String? inputDirectory;
if (snapshot.length >= _nativeThreshold &&
(useNative ?? PlatformBridge.supportsCoreBackend)) {
inputDirectory =
(await (temporaryDirectory ?? await getTemporaryDirectory()).createTemp(
'playlist-input-',
)).path;
}
try {
if (snapshot.length < _workerThreshold) {
return _prepare(snapshot, existingKeys, addedAt, inputDirectory);
}
return await Isolate.run(
() => _prepare(snapshot, existingKeys, addedAt, inputDirectory),
);
} catch (_) {
if (inputDirectory != null) await _removeDirectory(inputDirectory);
rethrow;
}
}
PreparedCollectionTrackBatch _prepare(
List<Track> tracks,
Set<String> existingKeys,
DateTime addedAt,
String? inputDirectory,
) {
final keys = <String>{};
final entries = <CollectionTrackEntry>[];
var duplicates = 0;
for (final track in tracks) {
final key = trackCollectionKey(track);
if (existingKeys.contains(key) || !keys.add(key)) {
duplicates++;
} else {
entries.add(
CollectionTrackEntry(key: key, track: track, addedAt: addedAt),
);
}
}
final rows = <Map<String, String>>[];
String? rowsPath;
if (inputDirectory != null && entries.length >= _nativeThreshold) {
rowsPath = '$inputDirectory/rows.ndjson';
final file = File(rowsPath).openSync(mode: FileMode.write);
try {
var buffer = StringBuffer();
for (final entry in entries) {
buffer.writeln(
jsonEncode({'key': entry.key, 'track': entry.track.toJson()}),
);
if (buffer.length >= 64 * 1024) {
file.writeStringSync(buffer.toString());
buffer = StringBuffer();
}
}
if (buffer.isNotEmpty) file.writeStringSync(buffer.toString());
} finally {
file.closeSync();
}
} else {
final timestamp = addedAt.toIso8601String();
for (final entry in entries) {
rows.add({
'track_key': entry.key,
'track_json': jsonEncode(entry.track.toJson()),
'added_at': timestamp,
});
}
}
return PreparedCollectionTrackBatch(
entries: entries,
duplicates: duplicates,
rows: rows,
rowsPath: rowsPath,
inputDirectory: inputDirectory,
);
}
/// Rust exclusively builds a new private database. Publication and the
/// playlist timestamp stay in one transaction on the existing sqflite owner.
Future<void> publishPlaylistBatch({
required Database database,
required String playlistId,
required String updatedAt,
required String rowsPath,
required int expectedCount,
Future<Map<String, dynamic>> Function(Map<String, dynamic>)? nativeJobRunner,
Directory? temporaryDirectory,
}) => _publishTrackBatch(
database: database,
playlistId: playlistId,
updatedAt: updatedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
nativeJobRunner: nativeJobRunner,
temporaryDirectory: temporaryDirectory,
);
Future<void> publishLovedBatch({
required Database database,
required String addedAt,
required String rowsPath,
required int expectedCount,
Future<Map<String, dynamic>> Function(Map<String, dynamic>)? nativeJobRunner,
Directory? temporaryDirectory,
}) => _publishTrackBatch(
database: database,
playlistId: null,
updatedAt: addedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
nativeJobRunner: nativeJobRunner,
temporaryDirectory: temporaryDirectory,
);
Future<void> _publishTrackBatch({
required Database database,
required String? playlistId,
required String updatedAt,
required String rowsPath,
required int expectedCount,
required Future<Map<String, dynamic>> Function(Map<String, dynamic>)?
nativeJobRunner,
required Directory? temporaryDirectory,
}) async {
// The native staging format contains only ordered keys and track JSON.
// Both destinations share it; only this owner chooses the live table.
final directory = await (temporaryDirectory ?? await getTemporaryDirectory())
.createTemp('playlist-stage-');
final stagedPath = '${directory.path}/playlist_additions.db';
var detached = true;
try {
final result = await (nativeJobRunner ?? PlatformBridge.runNativeDataJob)({
'operation': 'collections_stage_playlist_additions',
'rows_path': rowsPath,
'output_path': stagedPath,
'expected_count': expectedCount,
});
if (result['committed'] != true ||
result['published'] != true ||
result['count'] != expectedCount ||
result['path'] != stagedPath) {
throw const FormatException('Incomplete collection track staging');
}
final alias = 'playlist_additions_${_attachmentSequence++}';
await database.execute('ATTACH DATABASE ? AS $alias', [stagedPath]);
detached = false;
var committed = false;
try {
await sqlite.transactionWithBusyRetry(database, (txn) async {
if (playlistId == null) {
await txn.execute(
'INSERT OR REPLACE INTO main.loved_tracks '
'(track_key,track_json,added_at) '
'SELECT track_key,track_json,? FROM $alias.playlist_additions '
'ORDER BY position',
[updatedAt],
);
return;
}
await txn.execute(
'INSERT OR REPLACE INTO main.playlist_tracks '
'(playlist_id,track_key,track_json,added_at) '
'SELECT ?,track_key,track_json,? FROM $alias.playlist_additions '
'ORDER BY position',
[playlistId, updatedAt],
);
await txn.update(
'playlists',
{'updated_at': updatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
});
committed = true;
} finally {
try {
await detachNativeSqliteSnapshot(database, alias, stagedPath);
detached = true;
} on NativeSqliteSnapshotDetachException {
if (!committed) rethrow;
_log.w('Collection saved; retaining staging after failed DETACH');
}
}
} finally {
// This output directory is separate from the input batch so its owner can
// always remove the input even if a failed DETACH retains the SQLite file.
if (detached) await _removeDirectory(directory.path);
}
}
Future<void> _removeDirectory(String path) async {
try {
await Directory(path).delete(recursive: true);
} on FileSystemException catch (error) {
_log.w('Could not remove playlist staging: $error');
}
}