Files
plezy/lib/database/app_database.dart
T
edde746 7f0cad339c fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
2026-07-31 21:45:32 +02:00

1465 lines
58 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../media/ids.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables.dart';
import 'plex_metadata_recovery.dart';
import 'tvos_database_recovery_store.dart';
import '../models/download_models.dart';
import '../services/base_shared_preferences_service.dart';
import '../services/credential_vault.dart';
import '../utils/app_logger.dart';
import '../utils/serial_future_queue.dart';
import '../utils/global_key_utils.dart';
import '../utils/content_utils.dart';
part 'app_database.g.dart';
/// Action queued in the offline watch-progress sync table. The serialized
/// form ([id]) is what gets persisted in [OfflineWatchProgress.actionType];
/// keep these strings stable across renames so existing rows resolve.
enum OfflineActionType {
progress,
watched,
unwatched;
/// Stable string id used for persistence. Survives an enum-name rename
/// (e.g. `progress` → `inProgress`) — `.name` would corrupt every row.
String get id => switch (this) {
OfflineActionType.progress => 'progress',
OfflineActionType.watched => 'watched',
OfflineActionType.unwatched => 'unwatched',
};
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
/// silently fall back to the wrong action.
static OfflineActionType fromId(String id) => switch (id) {
'progress' => OfflineActionType.progress,
'watched' => OfflineActionType.watched,
'unwatched' => OfflineActionType.unwatched,
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
};
}
final class AppDatabaseBootstrap {
const AppDatabaseBootstrap({required this.database, required this.recoveryOutcome});
final AppDatabase database;
final TvosDatabaseRecoveryOutcome recoveryOutcome;
}
@DriftDatabase(
tables: [
DownloadedMedia,
DownloadOwners,
DownloadQueue,
ApiCache,
OfflineWatchProgress,
SyncRules,
SyncRuleDownloads,
Connections,
Profiles,
ProfileConnections,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase._(QueryExecutor executor, {TvosDatabaseRecoveryStore? recoveryStore})
: this._withRecovery(executor, recoveryStore);
/// Test-only constructor — inject an in-memory [QueryExecutor]
/// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk.
@visibleForTesting
AppDatabase.forTesting(QueryExecutor executor, {TvosDatabaseRecoveryStore? recoveryStore})
: this._withRecovery(executor, recoveryStore);
AppDatabase._withRecovery(super.e, this._recoveryStore);
final TvosDatabaseRecoveryStore? _recoveryStore;
final SerialFutureQueue _durabilityQueue = SerialFutureQueue();
static final Object _durabilityZoneKey = Object();
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
/// Resolves and opens the production database, removing orphaned WAL/SHM
/// sidecars when the main database is absent (#1732), then eagerly completing
/// Drift setup and migrations on non-tvOS. tvOS recovery retains ownership
/// of database access ordering.
static Future<AppDatabaseBootstrap> open({
bool isTvos = const bool.fromEnvironment('TVOS_BUILD'),
File? databaseFile,
SharedPreferencesWithCache? preferences,
QueryExecutor Function(File file)? executorFactory,
TvosDatabaseRecoveryStore? recoveryStore,
TvosDatabaseRecoveryPriorInstallEvidence? priorInstallEvidence,
}) async {
final file = databaseFile ?? await _resolveProductionDatabaseFile();
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
if (databaseFile == null && !Platform.isAndroid && !Platform.isIOS && !await file.exists()) {
await migrateLegacyDesktopDatabase(target: file);
}
final databaseExisted = await file.exists();
if (!databaseExisted) {
await _removeOrphanedDatabaseSidecars(file);
}
final prefs = preferences ?? await BaseSharedPreferencesService.sharedCache();
final store = recoveryStore ?? TvosDatabaseRecoveryStore(prefs, isTvos: isTvos);
final database = AppDatabase._((executorFactory ?? _createNativeDatabase)(file), recoveryStore: store);
try {
if (!isTvos) {
// Drift executors open lazily. Force the connection through setup and
// migrations while failures are still covered by this close/rethrow
// boundary and the caller's startup download-recovery decision.
await database.customSelect('SELECT 1').get();
// It deliberately does not claim capacity for a later write.
}
final outcome = await _tvosRecoveryQueue.run(
() => store.reconcile(
databaseExisted: databaseExisted,
readIdentity: database._readProtectedIdentityRecoveryRows,
readPending: database._readPendingRecoveryRows,
restore: database._restoreRecoverySnapshot,
hasPriorInstallEvidence:
priorInstallEvidence ??
() async {
return (prefs.getString('active_app_profile_id')?.isNotEmpty ?? false) ||
(prefs.getBool('profile_migration_v1_done') ?? false) ||
(prefs.getString('credential_vault_key_v1')?.isNotEmpty ?? false);
},
),
);
return AppDatabaseBootstrap(database: database, recoveryOutcome: outcome);
} catch (_) {
await database.close();
rethrow;
}
}
/// Wraps one complete registry identity mutation. Nested registry helpers
/// share the outer commit and all identity/pending commits are serialized.
Future<T> runIdentityMutation<T>(Future<T> Function() mutation) {
return _runDurableMutation(TvosDatabaseRecoveryGroup.identity, mutation);
}
/// Establishes a fresh committed recovery generation only after a user has
/// acknowledged [TvosDatabaseRecoveryOutcome.recoveryRequired] by starting
/// a new sign-in. This keeps invalid evidence blocking automatic bootstrap
/// while allowing the explicit recovery path to persist new identity rows.
Future<void> acknowledgeTvosDatabaseRecoveryRequired() {
final store = _recoveryStore;
if (store == null || !store.isTvos) return Future<void>.value();
return _durabilityQueue.run(
() => _tvosRecoveryQueue.run(
() => store.acknowledgeRecoveryRequired(
readIdentity: _readProtectedIdentityRecoveryRows,
readPending: _readPendingRecoveryRows,
),
),
);
}
Future<T> _runPendingMutation<T>(Future<T> Function() mutation) {
return _runDurableMutation(TvosDatabaseRecoveryGroup.pending, mutation);
}
Future<T> _runDurableMutation<T>(TvosDatabaseRecoveryGroup group, Future<T> Function() mutation) {
final store = _recoveryStore;
if (store == null || !store.isTvos) return mutation();
if (Zone.current[_durabilityZoneKey] == this) return mutation();
return _durabilityQueue.run(
() => _tvosRecoveryQueue.run(
() => runZoned(
() => store.runDurableMutation(
group: group,
mutation: mutation,
readIdentity: _readProtectedIdentityRecoveryRows,
readPending: _readPendingRecoveryRows,
),
zoneValues: {_durabilityZoneKey: this},
),
),
);
}
/// Recovery preferences are a second persisted copy of identity rows. Run
/// the same credential-vault cutover before reading those rows so a legacy
/// plaintext database can never become an authoritative plaintext image.
Future<Map<String, Object?>> _readProtectedIdentityRecoveryRows() async {
await _migrateLegacyCredentialsBeforeRecoverySnapshot();
return _readIdentityRecoveryRows();
}
Future<void> _migrateLegacyCredentialsBeforeRecoverySnapshot() async {
final connectionUpdates = <(String, String)>[];
for (final row in await select(connections).get()) {
final decoded = jsonDecode(row.configJson);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Invalid connection configuration');
}
if (!_containsPlaintextConnectionCredential(row.kind, decoded)) continue;
final protected = await CredentialVault.protectConnectionConfig(row.kind, decoded);
connectionUpdates.add((row.id, jsonEncode(protected)));
}
final tokenUpdates = <(String, String, String)>[];
for (final row in await select(profileConnections).get()) {
if (row.userToken.isEmpty || CredentialVault.isProtected(row.userToken)) continue;
tokenUpdates.add((row.profileId, row.connectionId, await CredentialVault.protect(row.userToken)));
}
if (connectionUpdates.isEmpty && tokenUpdates.isEmpty) return;
await transaction(() async {
for (final (id, configJson) in connectionUpdates) {
await (update(
connections,
)..where((table) => table.id.equals(id))).write(ConnectionsCompanion(configJson: Value(configJson)));
}
for (final (profileId, connectionId, token) in tokenUpdates) {
await (update(profileConnections)
..where((table) => table.profileId.equals(profileId) & table.connectionId.equals(connectionId)))
.write(ProfileConnectionsCompanion(userToken: Value(token)));
}
});
}
static bool _containsPlaintextConnectionCredential(String kind, Map<String, dynamic> config) {
bool isPlaintext(Object? value) => value is String && value.isNotEmpty && !CredentialVault.isProtected(value);
if (kind == 'jellyfin') return isPlaintext(config['accessToken']);
if (kind != 'plex') return false;
if (isPlaintext(config['accountToken'])) return true;
final servers = config['servers'];
return servers is List && servers.any((server) => server is Map && isPlaintext(server['accessToken']));
}
Future<Map<String, Object?>> _readIdentityRecoveryRows() async {
final connectionRows = await (select(connections)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
final profileRows = await (select(profiles)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
final joinRows = await (select(
profileConnections,
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get();
// Drift's generated serializer is the recovery image's column schema:
// `toJson`/`fromJson` use these camelCase keys, so the read and restore
// sides can never drift apart when a column is added or renamed.
return {
'connections': [for (final row in connectionRows) row.toJson()],
'profiles': [for (final row in profileRows) row.toJson()],
'profileConnections': [for (final row in joinRows) row.toJson()],
};
}
Future<Map<String, Object?>> _readPendingRecoveryRows() async {
final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
return {
'offlineWatchProgress': [for (final row in rows) row.toJson()],
};
}
Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async {
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson);
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson);
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson);
final pendingRows = _decodeRecoveryRows(
snapshot.pending,
'offlineWatchProgress',
OfflineWatchProgressItem.fromJson,
);
// Recovery images from releases before the credential vault may contain
// plaintext secrets. Protect them before they cross into Drift; already
// protected values remain byte-identical because vault protection is
// idempotent.
for (var index = 0; index < connectionRows.length; index++) {
final row = connectionRows[index];
final decoded = jsonDecode(row.configJson);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Invalid connection configuration');
}
if (_containsPlaintextConnectionCredential(row.kind, decoded)) {
connectionRows[index] = row.copyWith(
configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)),
);
}
}
for (var index = 0; index < joinRows.length; index++) {
final row = joinRows[index];
if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) {
joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken));
}
}
await transaction(() async {
// Recovery completion (the durable marker removal) is deliberately
// separate from this transaction. Replace the snapshot-owned rows so a
// restart can replay the same committed image after a crash or marker
// removal failure without hitting primary-key conflicts.
await delete(profileConnections).go();
await delete(profiles).go();
await delete(connections).go();
await delete(offlineWatchProgress).go();
// `toCompanion(false)` writes every column explicitly, including the
// nulls, so a restored row is byte-identical to the captured one rather
// than picking up column defaults.
for (final row in connectionRows) {
await into(connections).insert(row.toCompanion(false));
}
for (final row in profileRows) {
await into(profiles).insert(row.toCompanion(false));
}
for (final row in joinRows) {
await into(profileConnections).insert(row.toCompanion(false));
}
for (final row in pendingRows) {
await into(offlineWatchProgress).insert(row.toCompanion(false));
}
});
}
static List<T> _decodeRecoveryRows<T extends DataClass>(
Map<String, Object?> group,
String key,
T Function(Map<String, dynamic> json) fromJson,
) {
final value = group[key];
if (value is! List) throw _invalidRecoveryImage;
return [
for (final row in value)
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
];
}
/// Reads one row through drift's generated deserializer and rejects anything
/// that does not round-trip back to the exact same map. Drift already throws
/// on a missing or mistyped required column; the round-trip additionally
/// rejects unknown and missing-but-nullable columns, which the serializer
/// would otherwise accept silently.
static T _decodeRecoveryRow<T extends DataClass>(
Map<String, dynamic> row,
T Function(Map<String, dynamic> json) fromJson,
) {
final T decoded;
try {
decoded = fromJson(row);
} catch (_) {
throw _invalidRecoveryImage;
}
if (!mapEquals(decoded.toJson(), row)) throw _invalidRecoveryImage;
return decoded;
}
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
@override
int get schemaVersion => 20;
@override
MigrationStrategy get migration {
return MigrationStrategy(
// Enforce ProfileConnections → Profiles/Connections cascades.
// Drift turns FKs *off* during migrations, so the per-connection
// pragma we set in `_openConnection` is wiped on first open. This
// hook runs after migrations and re-enables it for subsequent
// queries — also applies to in-memory test databases that don't go
// through `_openConnection`.
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 7) {
appLogger.i('Adding OfflineWatchProgress table (v7 migration)');
await _ignoreAlreadyExists('OfflineWatchProgress table', () => m.createTable(offlineWatchProgress));
}
if (from < 8) {
appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.bgTaskId column',
() => m.addColumn(downloadedMedia, downloadedMedia.bgTaskId),
);
}
if (from < 9) {
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.mediaIndex column',
() => m.addColumn(downloadedMedia, downloadedMedia.mediaIndex),
);
}
if (from < 10) {
appLogger.i('Adding SyncRules table (v10 migration)');
await _ignoreAlreadyExists('SyncRules table', () => m.createTable(syncRules));
}
if (from < 11) {
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
await _ignoreAlreadyExists('SyncRules.enabled column', () => m.addColumn(syncRules, syncRules.enabled));
}
if (from < 12) {
appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)');
await _ignoreAlreadyExists(
'SyncRules.downloadFilter column',
() => m.addColumn(syncRules, syncRules.downloadFilter),
);
}
if (from < 13) {
appLogger.i('Adding indexes on DownloadedMedia hot-queried columns (v13 migration)');
final indexes = {
'idx_downloaded_media_status': idxDownloadedMediaStatus,
'idx_downloaded_media_server': idxDownloadedMediaServer,
'idx_downloaded_media_parent': idxDownloadedMediaParent,
'idx_downloaded_media_grandparent': idxDownloadedMediaGrandparent,
};
for (final entry in indexes.entries) {
await _ignoreAlreadyExists('Index ${entry.key}', () => m.create(entry.value));
}
}
if (from < 14) {
appLogger.i(
'Adding Connections, Profiles, ProfileConnections, DownloadOwners + scope/profile columns (v14 migration)',
);
await _ignoreAlreadyExists('Connections table', () => m.createTable(connections));
await _ignoreAlreadyExists('Index idx_connections_kind', () => m.create(idxConnectionsKind));
await _ignoreAlreadyExists('Profiles table', () => m.createTable(profiles));
await _ignoreAlreadyExists('Index idx_profiles_kind', () => m.create(idxProfilesKind));
await _ignoreAlreadyExists('ProfileConnections table', () => m.createTable(profileConnections));
await _ignoreAlreadyExists(
'Index idx_profile_connections_connection_id',
() => m.create(idxProfileConnectionsConnectionId),
);
await _ignoreAlreadyExists(
'Index idx_profile_connections_profile_id',
() => m.create(idxProfileConnectionsProfileId),
);
await _ignoreAlreadyExists('DownloadOwners table', () => m.createTable(downloadOwners));
await _ignoreAlreadyExists('Index idx_download_owners_profile', () => m.create(idxDownloadOwnersProfile));
await _ignoreAlreadyExists(
'Index idx_download_owners_global_key',
() => m.create(idxDownloadOwnersGlobalKey),
);
await _ignoreAlreadyExists(
'DownloadedMedia.clientScopeId column',
() => m.addColumn(downloadedMedia, downloadedMedia.clientScopeId),
);
await _ignoreAlreadyExists(
'OfflineWatchProgress.clientScopeId column',
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.clientScopeId),
);
await _ignoreAlreadyExists('SyncRules.profileId column', () => m.addColumn(syncRules, syncRules.profileId));
await _ignoreAlreadyExists(
'OfflineWatchProgress.profileId column',
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.profileId),
);
await customStatement('''
UPDATE downloaded_media
SET client_scope_id = (
SELECT id FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
LIMIT 1
)
WHERE client_scope_id IS NULL
AND EXISTS (
SELECT 1 FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
)
''');
await customStatement('''
UPDATE offline_watch_progress
SET client_scope_id = (
SELECT id FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
LIMIT 1
)
WHERE client_scope_id IS NULL
AND EXISTS (
SELECT 1 FROM connections
WHERE kind = 'jellyfin'
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
)
''');
await _ignoreAlreadyExists(
'Index idx_offline_watch_progress_server',
() => m.create(idxOfflineWatchProgressServer),
);
await _ignoreAlreadyExists('Index idx_sync_rules_profile', () => m.create(idxSyncRulesProfile));
await _ignoreAlreadyExists(
'Index idx_offline_watch_progress_profile',
() => m.create(idxOfflineWatchProgressProfile),
);
}
if (from < 15) {
appLogger.i('Adding mediaSourceId column to DownloadedMedia (v15 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.mediaSourceId column',
() => m.addColumn(downloadedMedia, downloadedMedia.mediaSourceId),
);
}
if (from < 16) {
appLogger.i('Adding includeSpecials column to SyncRules (v16 migration)');
await _ignoreAlreadyExists(
'SyncRules.includeSpecials column',
() => m.addColumn(syncRules, syncRules.includeSpecials),
);
}
if (from < 17) {
appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)');
await customStatement(
_rescopePinnedPlexMetadataStatement(
namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'",
ownerJoin: '''JOIN download_owners AS owner
ON owner.global_key = metadata.global_key''',
),
);
// A direct pre-v14 upgrade has no owners yet: profiles and owner
// adoption are bootstrapped only after the database opens. Preserve
// those downloads in the neutral Plex transfer namespace so the
// first profile can adopt them without inheriting legacy watch data.
await customStatement(
_rescopePinnedPlexMetadataStatement(
namespaceExpression: "'/~plex-transfer:'",
ownerFilter: '''AND NOT EXISTS (
SELECT 1
FROM download_owners AS owner
WHERE owner.global_key = metadata.global_key
)''',
),
);
final transferRows = await customSelect('''
SELECT cache_key, data
FROM api_cache
WHERE instr(
substr(cache_key, 1, instr(cache_key, ':') - 1),
'/~plex-transfer'
) > 0
AND instr(cache_key, ':/library/metadata/') > 0
''').get();
for (final row in transferRows) {
final cacheKey = row.read<String>('cache_key');
try {
final sanitized = sanitizePlexMetadataForOwnerlessTransfer(row.read<String>('data'));
await customStatement('UPDATE api_cache SET data = ? WHERE cache_key = ?', [sanitized, cacheKey]);
} on FormatException catch (error, stackTrace) {
appLogger.w(
'Discarding invalid legacy Plex transfer metadata for $cacheKey',
error: error,
stackTrace: stackTrace,
);
await customStatement('DELETE FROM api_cache WHERE cache_key = ?', [cacheKey]);
}
}
// Only mark a physical row as transferable when its sanitized leaf
// exists. Parent-only cache remnants cannot hydrate an offline item.
await customStatement('''
UPDATE downloaded_media
SET client_scope_id = server_id || '/~plex-transfer'
WHERE NOT EXISTS (
SELECT 1
FROM download_owners AS owner
WHERE owner.global_key = downloaded_media.global_key
)
AND EXISTS (
SELECT 1
FROM api_cache AS transfer
WHERE transfer.cache_key =
downloaded_media.server_id
|| '/~plex-transfer:/library/metadata/'
|| downloaded_media.rating_key
AND transfer.pinned = 1
)
''');
await customStatement('''
WITH legacy_plex_metadata AS (
SELECT
cache_key,
substr(cache_key, instr(cache_key, ':') + 1) AS endpoint
FROM api_cache
WHERE instr(cache_key, ':/library/metadata/') > 0
AND instr(
substr(cache_key, 1, instr(cache_key, ':') - 1),
'/~plex-profile/'
) = 0
AND instr(
substr(cache_key, 1, instr(cache_key, ':') - 1),
'/~plex-transfer'
) = 0
)
DELETE FROM api_cache
WHERE cache_key IN (
SELECT cache_key
FROM legacy_plex_metadata
WHERE (
endpoint GLOB '/library/metadata/?*'
AND endpoint NOT GLOB '/library/metadata/*/*'
) OR (
endpoint GLOB '/library/metadata/?*/children'
AND endpoint NOT GLOB '/library/metadata/*/*/*'
)
)
''');
}
if (from < 18) {
appLogger.i('Adding safRootUri column to DownloadedMedia (v18 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.safRootUri column',
() => m.addColumn(downloadedMedia, downloadedMedia.safRootUri),
);
}
if (from < 19) {
appLogger.i('Adding backend metadata scope columns to DownloadOwners (v19 migration)');
await _ignoreAlreadyExists(
'DownloadOwners.backend column',
() => m.addColumn(downloadOwners, downloadOwners.backend),
);
await _ignoreAlreadyExists(
'DownloadOwners.clientScopeId column',
() => m.addColumn(downloadOwners, downloadOwners.clientScopeId),
);
await customStatement('''
UPDATE download_owners
SET client_scope_id = CASE
WHEN EXISTS (
SELECT 1
FROM downloaded_media
WHERE downloaded_media.global_key = download_owners.global_key
AND downloaded_media.client_scope_id LIKE '%/~plex-profile/%'
) THEN (
SELECT downloaded_media.server_id || '/~plex-profile/' || download_owners.profile_id
FROM downloaded_media
WHERE downloaded_media.global_key = download_owners.global_key
)
WHEN EXISTS (
SELECT 1
FROM downloaded_media
JOIN profile_connections
ON profile_connections.profile_id = download_owners.profile_id
JOIN connections
ON connections.id = profile_connections.connection_id
WHERE downloaded_media.global_key = download_owners.global_key
AND connections.kind = 'jellyfin'
AND profile_connections.user_identifier != ''
AND (
connections.id = downloaded_media.server_id
OR substr(connections.id, 1, length(downloaded_media.server_id) + 1)
= downloaded_media.server_id || '/'
)
) THEN (
SELECT CASE
WHEN connections.id = downloaded_media.server_id
THEN downloaded_media.server_id || '/' || profile_connections.user_identifier
ELSE connections.id
END
FROM downloaded_media
JOIN profile_connections
ON profile_connections.profile_id = download_owners.profile_id
JOIN connections
ON connections.id = profile_connections.connection_id
WHERE downloaded_media.global_key = download_owners.global_key
AND connections.kind = 'jellyfin'
AND profile_connections.user_identifier != ''
AND (
connections.id = downloaded_media.server_id
OR substr(connections.id, 1, length(downloaded_media.server_id) + 1)
= downloaded_media.server_id || '/'
)
ORDER BY profile_connections.is_default DESC,
profile_connections.last_used_at DESC,
connections.id
LIMIT 1
)
ELSE NULL
END
WHERE client_scope_id IS NULL
''');
await customStatement('''
UPDATE download_owners
SET backend = CASE
WHEN client_scope_id LIKE '%/~plex-profile/%' THEN 'plex'
WHEN client_scope_id IS NOT NULL AND EXISTS (
SELECT 1
FROM downloaded_media
JOIN profile_connections
ON profile_connections.profile_id = download_owners.profile_id
JOIN connections
ON connections.id = profile_connections.connection_id
WHERE downloaded_media.global_key = download_owners.global_key
AND connections.kind = 'jellyfin'
AND (
connections.id = downloaded_media.server_id
OR substr(connections.id, 1, length(downloaded_media.server_id) + 1)
= downloaded_media.server_id || '/'
)
) THEN 'jellyfin'
END
WHERE backend IS NULL
''');
}
if (from < 20) {
appLogger.i('Adding sync rule download associations (v20 migration)');
await _ignoreAlreadyExists(
'SyncRules.downloadLinksInitialized column',
() => m.addColumn(syncRules, syncRules.downloadLinksInitialized),
);
await _ignoreAlreadyExists('SyncRuleDownloads table', () => m.createTable(syncRuleDownloads));
await _ignoreAlreadyExists(
'Index idx_sync_rule_downloads_profile_key',
() => m.create(idxSyncRuleDownloadsProfileKey),
);
}
},
);
}
Future<void> _ignoreAlreadyExists(String label, Future<void> Function() operation) async {
try {
await operation();
} catch (e) {
final message = e.toString().toLowerCase();
if (message.contains('already exists') || message.contains('duplicate column name')) {
appLogger.w('$label already exists during migration: $e');
return;
}
rethrow;
}
}
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
return value == null ? column.isNull() : column.equals(value);
}
/// Get all pending offline watch actions for sync
Future<List<OfflineWatchProgressItem>> getPendingWatchActions({String? profileId}) {
final query = select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)]);
if (profileId != null) {
query.where((t) => t.profileId.equals(profileId));
}
return query.get();
}
/// Claim pre-v18 offline watch actions for [profileId]. Those rows predate
/// profile ownership and have `NULL profile_id`; the first active profile
/// inherits them so already-watched offline progress is not stranded.
Future<void> adoptLegacyOfflineWatchActionsForProfile(String profileId) async {
if (profileId.isEmpty) return;
await _runPendingMutation(
() => (update(
offlineWatchProgress,
)..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId))),
);
}
/// Get pending watch actions for a specific server
@visibleForTesting
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
return (select(offlineWatchProgress)
..where(
(t) =>
t.serverId.equals(serverId) &
(profileId == null ? const Constant(true) : t.profileId.equals(profileId)),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.get();
}
SimpleSelectStatement<$OfflineWatchProgressTable, OfflineWatchProgressItem> _watchActionsQuery(
Expression<bool> Function($OfflineWatchProgressTable table) matchesKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return select(offlineWatchProgress)
..where(
(t) =>
matchesKey(t) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _nullableTextPredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
}
/// Get the latest action for a specific item
@visibleForTesting
Future<OfflineWatchProgressItem?> getLatestWatchAction(
String globalKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (_watchActionsQuery(
(t) => t.globalKey.equals(globalKey),
profileId: profileId,
filterProfile: filterProfile,
clientScopeId: clientScopeId,
filterClientScope: filterClientScope,
)..limit(1)).getSingleOrNull();
}
Future<List<OfflineWatchProgressItem>> getWatchActionsForKey(
String globalKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return _watchActionsQuery(
(t) => t.globalKey.equals(globalKey),
profileId: profileId,
filterProfile: filterProfile,
clientScopeId: clientScopeId,
filterClientScope: filterClientScope,
).get();
}
Future<Map<String, List<OfflineWatchProgressItem>>> getWatchActionsForKeys(
Set<String> globalKeys, {
String? profileId,
bool filterProfile = false,
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
if (globalKeys.isEmpty) return const {};
final rows = await _watchActionsQuery(
(t) => t.globalKey.isIn(globalKeys),
profileId: profileId,
filterProfile: filterProfile,
).get();
final result = <String, List<OfflineWatchProgressItem>>{};
for (final action in rows) {
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
}
result.putIfAbsent(action.globalKey, () => <OfflineWatchProgressItem>[]).add(action);
}
return result;
}
/// Get the latest actions for multiple items in a single query
///
/// Returns a map of globalKey -> latest action for each key.
/// Keys with no actions will not be present in the returned map.
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(
Set<String> globalKeys, {
String? profileId,
bool filterProfile = false,
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
final actionsByKey = await getWatchActionsForKeys(
globalKeys,
profileId: profileId,
filterProfile: filterProfile,
clientScopeIdsByGlobalKey: clientScopeIdsByGlobalKey,
);
return {for (final entry in actionsByKey.entries) entry.key: entry.value.first};
}
bool _clientScopeValuesMatch(String? actual, String? expected) {
final normalizedActual = actual == null || actual.isEmpty ? null : actual;
final normalizedExpected = expected == null || expected.isEmpty ? null : expected;
return normalizedActual == normalizedExpected;
}
/// Insert or update a progress action (merges with existing).
Future<void> upsertProgressAction({
String? profileId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required int viewOffset,
required int? duration,
required bool shouldMarkWatched,
}) async {
return _runPendingMutation(() async {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
final existing =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_nullableTextPredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id),
)
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();
final keep = existing.isEmpty ? null : existing.first;
if (keep != null) {
await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
updatedAt: Value(now),
),
);
final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false);
if (duplicateIds.isNotEmpty) {
await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go();
}
} else {
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
}
});
});
}
/// Insert a manual watch action (watched or unwatched).
/// Removes conflicting actions for the same item.
Future<void> insertWatchAction({
String? profileId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String actionType, // 'watched' or 'unwatched'
}) async {
return _runPendingMutation(() async {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
// Remove conflicting actions (opposite action type and progress).
await (delete(offlineWatchProgress)..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_nullableTextPredicate(t.clientScopeId, clientScopeId),
))
.go();
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: actionType,
createdAt: now,
updatedAt: now,
),
);
});
});
}
/// Delete a specific watch action after successful sync
Future<void> deleteWatchAction(int id) {
return _runPendingMutation(() async {
await (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go();
});
}
/// Update sync attempt count and error message
Future<void> updateSyncAttempt(int id, String? errorMessage) async {
return _runPendingMutation(() async {
final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull();
if (existing != null) {
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)),
);
}
});
}
/// Get count of pending sync items
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
if (profileId != null) {
query.where(offlineWatchProgress.profileId.equals(profileId));
}
if (maxSyncAttempts != null) {
query.where(offlineWatchProgress.syncAttempts.isSmallerThanValue(maxSyncAttempts));
}
final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle();
return count ?? 0;
}
/// Clear all pending watch actions (e.g., after logout)
Future<void> clearAllWatchActions() {
return _runPendingMutation(() async {
await delete(offlineWatchProgress).go();
});
}
/// Drop a removed profile's queued watch actions (profile teardown).
Future<void> deleteWatchActionsForProfile(String profileId) async {
await _runPendingMutation(() async {
await (delete(offlineWatchProgress)..where((t) => t.profileId.equals(profileId))).go();
});
}
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
final query = select(syncRules);
if (profileId != null) {
query.where((t) => t.profileId.equals(profileId));
}
return query.get();
}
Future<SyncRuleItem?> getSyncRule(String globalKey) {
return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> associateSyncRuleDownload(SyncRuleItem rule, String downloadGlobalKey) {
return into(syncRuleDownloads).insertOnConflictUpdate(
SyncRuleDownloadsCompanion.insert(
syncRuleId: rule.id,
profileId: rule.profileId,
downloadGlobalKey: downloadGlobalKey,
),
);
}
Future<void> markSyncRuleDownloadLinksInitialized(String globalKey) {
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
const SyncRulesCompanion(downloadLinksInitialized: Value(true)),
);
}
/// Returns uninitialized collection/playlist rules and every show/season
/// rule whose local ancestry-derived links must be refreshed before cleanup.
Future<List<SyncRuleItem>> getUninitializedSyncRulesForServer({
required String profileId,
required ServerId serverId,
}) {
return (select(syncRules)..where(
(t) =>
t.profileId.equals(profileId) &
t.serverId.equals(serverId) &
(t.downloadLinksInitialized.equals(false) |
t.targetType.isIn(const [ContentTypes.show, ContentTypes.season])),
))
.get();
}
Future<List<SyncRuleDownloadItem>> getSyncRuleDownloadLinks(int syncRuleId) {
return (select(syncRuleDownloads)..where((t) => t.syncRuleId.equals(syncRuleId))).get();
}
Future<List<String>> getOwnedDownloadKeysForAncestorRule({
required String profileId,
required ServerId serverId,
required String ratingKey,
required bool matchGrandparent,
}) async {
final query = select(
downloadedMedia,
).join([innerJoin(downloadOwners, downloadOwners.globalKey.equalsExp(downloadedMedia.globalKey))]);
final ancestorMatches = matchGrandparent
? downloadedMedia.grandparentRatingKey.equals(ratingKey) | downloadedMedia.parentRatingKey.equals(ratingKey)
: downloadedMedia.parentRatingKey.equals(ratingKey);
query.where(
downloadOwners.profileId.equals(profileId) &
downloadedMedia.serverId.equals(serverId) &
downloadedMedia.status.isIn([
DownloadStatus.queued.index,
DownloadStatus.downloading.index,
DownloadStatus.completed.index,
DownloadStatus.paused.index,
]) &
ancestorMatches,
);
final rows = await query.get();
return rows.map((row) => row.readTable(downloadedMedia).globalKey).toList(growable: false);
}
Future<List<String>> getExclusiveSyncRuleDownloadKeys(SyncRuleItem rule) async {
final links = await getSyncRuleDownloadLinks(rule.id);
if (links.isEmpty) return const [];
final keys = links.map((link) => link.downloadGlobalKey).toSet();
final allLinks = await (select(
syncRuleDownloads,
)..where((t) => t.profileId.equals(rule.profileId) & t.downloadGlobalKey.isIn(keys))).get();
final linkedRuleCounts = <String, int>{};
for (final link in allLinks) {
linkedRuleCounts.update(link.downloadGlobalKey, (count) => count + 1, ifAbsent: () => 1);
}
return [
for (final key in keys)
if (linkedRuleCounts[key] == 1) key,
];
}
Future<void> insertSyncRule({
String profileId = '',
required ServerId serverId,
required String ratingKey,
required String globalKey,
required String targetType,
required int episodeCount,
int mediaIndex = 0,
String downloadFilter = 'unwatched',
bool includeSpecials = true,
}) async {
// [insertOnConflictUpdate] defaults the conflict target to the primary
// key (`id`), which is auto-incremented — the conflict never triggers
// and the row's UNIQUE [globalKey] constraint blows up instead. Drive
// the upsert off the public [globalKey] so re-creating a rule for the same
// shared target updates the existing row.
await into(syncRules).insert(
SyncRulesCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
ratingKey: ratingKey,
globalKey: globalKey,
targetType: targetType,
episodeCount: episodeCount,
createdAt: DateTime.now().millisecondsSinceEpoch,
mediaIndex: Value(mediaIndex),
downloadFilter: Value(downloadFilter),
includeSpecials: Value(includeSpecials),
),
onConflict: DoUpdate(
(_) => SyncRulesCompanion(
serverId: Value(serverId),
profileId: Value(profileId),
ratingKey: Value(ratingKey),
targetType: Value(targetType),
episodeCount: Value(episodeCount),
mediaIndex: Value(mediaIndex),
downloadFilter: Value(downloadFilter),
includeSpecials: Value(includeSpecials),
),
target: [syncRules.globalKey],
),
);
}
/// Claim pre-v16 public sync rules for [profileId]. Rules created before
/// profile ownership have an empty profile id and a public global key.
Future<void> adoptLegacySyncRulesForProfile(String profileId) async {
if (profileId.isEmpty) return;
final legacyRules = await (select(syncRules)..where((t) => t.profileId.equals(''))).get();
for (final rule in legacyRules) {
final scopedKey = buildProfileScopedGlobalKey(profileId, ServerId(rule.serverId), rule.ratingKey);
final duplicate = await getSyncRule(scopedKey);
if (duplicate != null) {
await (delete(syncRules)..where((t) => t.id.equals(rule.id))).go();
continue;
}
await (update(syncRules)..where((t) => t.id.equals(rule.id))).write(
SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)),
);
await (update(
syncRuleDownloads,
)..where((t) => t.syncRuleId.equals(rule.id))).write(SyncRuleDownloadsCompanion(profileId: Value(profileId)));
}
}
Future<void> _writeSyncRule(String globalKey, SyncRulesCompanion values) async {
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values);
}
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) =>
_writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount)));
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) =>
_writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
Future<void> completeSyncRuleExecution(String globalKey) {
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
SyncRulesCompanion(
lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch),
downloadLinksInitialized: const Value(true),
),
);
}
Future<void> deleteSyncRule(String globalKey) async {
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
}
/// Drop a removed profile's sync rules (profile teardown).
Future<void> deleteSyncRulesForProfile(String profileId) async {
await (delete(syncRules)..where((t) => t.profileId.equals(profileId))).go();
}
/// Drop every sync rule (full logout).
Future<void> clearAllSyncRules() async {
await delete(syncRules).go();
}
/// Get all downloaded media items (for syncing watch states)
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
}
}
/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into
/// a scoped cache namespace.
///
/// The owned and the ownerless branch run the same operation over the same
/// `download_metadata_ids` set and differ only in three spots: the expression
/// spliced into the new `cache_key` ([namespaceExpression]), an optional join
/// that exposes the owning profile ([ownerJoin]), and an optional extra
/// predicate that keeps each branch to its own rows ([ownerFilter]).
String _rescopePinnedPlexMetadataStatement({
required String namespaceExpression,
String ownerJoin = '',
String ownerFilter = '',
}) =>
'''
WITH download_metadata_ids AS (
SELECT global_key, server_id, rating_key AS metadata_id
FROM downloaded_media
UNION
SELECT global_key, server_id, parent_rating_key AS metadata_id
FROM downloaded_media
WHERE parent_rating_key IS NOT NULL
AND parent_rating_key != ''
UNION
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
FROM downloaded_media
WHERE grandparent_rating_key IS NOT NULL
AND grandparent_rating_key != ''
)
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
SELECT DISTINCT
metadata.server_id
|| $namespaceExpression
|| substr(source.cache_key, length(metadata.server_id) + 2),
source.data,
source.pinned,
source.cached_at
FROM download_metadata_ids AS metadata
$ownerJoin
JOIN api_cache AS source
ON source.cache_key =
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
OR source.cache_key =
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
WHERE source.pinned = 1
$ownerFilter
ON CONFLICT(cache_key) DO UPDATE SET
data = excluded.data,
pinned = excluded.pinned,
cached_at = excluded.cached_at
''';
Future<File> _resolveProductionDatabaseFile() async {
final dbFolder = (Platform.isAndroid || Platform.isIOS)
? await getApplicationDocumentsDirectory()
: await getApplicationSupportDirectory();
return File(p.join(dbFolder.path, 'plezy_downloads.db'));
}
/// Best-effort removal for sidecars left without a main database after an
/// interrupted write. This runs on every platform but only when the caller has
/// confirmed the main database is absent (#1732).
Future<void> _removeOrphanedDatabaseSidecars(File databaseFile) async {
for (final suffix in const ['-wal', '-shm']) {
final sidecar = File('${databaseFile.path}$suffix');
try {
if (await sidecar.exists()) await sidecar.delete();
} on FileSystemException catch (error, stackTrace) {
appLogger.w('Unable to remove orphaned database sidecar ${sidecar.path}', error: error, stackTrace: stackTrace);
}
}
}
QueryExecutor _createNativeDatabase(File file) {
return NativeDatabase.createInBackground(
file,
setup: (db) {
db.execute('PRAGMA journal_mode=WAL');
db.execute('PRAGMA synchronous=NORMAL');
// Enforce ProfileConnections → Connections cascades.
// SQLite requires this on every connection — it is not persisted.
db.execute('PRAGMA foreign_keys = ON');
},
);
}
/// Move the legacy desktop DB from `Documents/` to `ApplicationSupport/`.
/// `File.rename` only works within a single volume — Windows users with
/// OneDrive-redirected Documents (or any cross-drive setup) hit
/// `ERROR_NOT_SAME_DEVICE` (errno 17), and the uncaught throw used to
/// strand the splash on "Loading servers..." forever (#1022). Falls back
/// to a synced sibling temporary copy followed by an atomic rename on any
/// [FileSystemException], and swallows all errors so a failed migration
/// never propagates fatally. The canonical-path lock file is intentionally
/// retained: deleting it could let a new process lock a different inode while
/// an existing waiter still holds the old one.
///
/// [sourceOverride], [renameOverride], [copyOverride], and [publishOverride]
/// are test seams — production callers leave them null.
Future<void> migrateLegacyDesktopDatabase({
required File target,
File? sourceOverride,
Future<void> Function(File source, String targetPath)? renameOverride,
Future<void> Function(File source, File temporary)? copyOverride,
Future<void> Function(File temporary, File target)? publishOverride,
}) async {
final File oldFile;
try {
if (sourceOverride != null) {
oldFile = sourceOverride;
} else {
final oldFolder = await getApplicationDocumentsDirectory();
oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
}
if (!await oldFile.exists()) return;
} catch (e, st) {
appLogger.w('Legacy DB migration skipped before source lookup completed', error: e, stackTrace: st);
return;
}
try {
final moved = await _withLegacyDatabasePublishLock(target, () async {
if (await target.exists()) {
appLogger.w('Legacy DB migration skipped because ${target.path} now exists');
return false;
}
if (renameOverride != null) {
await renameOverride(oldFile, target.path);
} else {
await oldFile.rename(target.path);
}
return true;
});
if (!moved) return;
appLogger.i('Moved legacy DB from ${oldFile.path}${target.path}');
return;
} on FileSystemException catch (e) {
appLogger.w('Legacy DB rename failed (osError=${e.osError?.errorCode}); falling back to copy', error: e);
}
final temporary = File(
p.join(
target.parent.path,
'.${p.basename(target.path)}.legacy-migration-$pid-${DateTime.now().microsecondsSinceEpoch}.tmp',
),
);
try {
if (copyOverride != null) {
await copyOverride(oldFile, temporary);
} else {
await _copyFileAndSync(oldFile, temporary);
}
final published = await _withLegacyDatabasePublishLock(target, () async {
// Recheck only while holding the inter-process lock. On POSIX, rename
// replaces an existing destination, so an unlocked check can race a
// concurrent publisher and overwrite its now-canonical database.
if (await target.exists()) {
appLogger.w('Legacy DB migration skipped because ${target.path} now exists');
return false;
}
// The temporary file is a sibling, so this rename stays on one volume
// and publishes the complete, synced copy atomically.
if (publishOverride != null) {
await publishOverride(temporary, target);
} else {
await temporary.rename(target.path);
}
return true;
});
if (!published) return;
try {
await oldFile.delete();
} catch (e) {
// Leaving the source behind is non-fatal — the new file is canonical.
appLogger.w('Legacy DB copied but old file delete failed: $e');
}
appLogger.i('Copied legacy DB from ${oldFile.path}${target.path}');
} catch (e, st) {
// A failed copy or final rename never touches the canonical path. Keep
// the legacy source so a future launch can retry.
appLogger.e('Legacy DB migration failed entirely', error: e, stackTrace: st);
} finally {
try {
if (await temporary.exists()) await temporary.delete();
} catch (e, st) {
appLogger.w('Failed to clean legacy DB migration temporary file', error: e, stackTrace: st);
}
}
}
Future<T> _withLegacyDatabasePublishLock<T>(File target, Future<T> Function() action) async {
final lockFile = File(p.join(target.parent.path, '.${p.basename(target.path)}.legacy-migration.lock'));
final handle = await lockFile.open(mode: FileMode.append);
var locked = false;
try {
await handle.lock(FileLock.blockingExclusive);
locked = true;
return await action();
} finally {
try {
if (locked) await handle.unlock();
} finally {
await handle.close();
}
}
}
Future<void> _copyFileAndSync(File source, File destination) async {
final input = await source.open();
try {
final output = await destination.open(mode: FileMode.writeOnly);
try {
final buffer = Uint8List(64 * 1024);
while (true) {
final bytesRead = await input.readInto(buffer);
if (bytesRead == 0) break;
await output.writeFrom(buffer, 0, bytesRead);
}
await output.flush();
} finally {
await output.close();
}
} finally {
await input.close();
}
}