Files
plezy/lib/database/app_database.dart
T

353 lines
12 KiB
Dart

import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables.dart';
import '../models/download_models.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
part 'app_database.g.dart';
/// String values stored in [OfflineWatchProgress.actionType] (use `.name`).
enum OfflineActionType { progress, watched, unwatched }
// Simplified database with API cache for offline support
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress, SyncRules])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
/// Test-only constructor — inject an in-memory [QueryExecutor]
/// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk.
@visibleForTesting
AppDatabase.forTesting(super.e);
@override
int get schemaVersion => 13;
@override
MigrationStrategy get migration {
return MigrationStrategy(
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 m.createTable(offlineWatchProgress);
}
if (from < 8) {
appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)');
try {
await m.addColumn(downloadedMedia, downloadedMedia.bgTaskId);
} catch (e) {
appLogger.w('bgTaskId column may already exist: $e');
}
}
if (from < 9) {
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
try {
await m.addColumn(downloadedMedia, downloadedMedia.mediaIndex);
} catch (e) {
appLogger.w('mediaIndex column may already exist: $e');
}
}
if (from < 10) {
appLogger.i('Adding SyncRules table (v10 migration)');
await m.createTable(syncRules);
}
if (from < 11) {
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
try {
await m.addColumn(syncRules, syncRules.enabled);
} catch (e) {
appLogger.w('enabled column may already exist: $e');
}
}
if (from < 12) {
appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)');
try {
await m.addColumn(syncRules, syncRules.downloadFilter);
} catch (e) {
appLogger.w('downloadFilter column may already exist: $e');
}
}
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) {
try {
await m.create(entry.value);
} catch (e) {
appLogger.w('Index ${entry.key} may already exist: $e');
}
}
}
},
);
}
// ============================================================
// Offline Watch Progress Operations
// ============================================================
/// Get all pending offline watch actions for sync
Future<List<OfflineWatchProgressItem>> getPendingWatchActions() {
return (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
}
/// Get pending watch actions for a specific server
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId) {
return (select(offlineWatchProgress)
..where((t) => t.serverId.equals(serverId))
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.get();
}
/// Get the latest action for a specific item
Future<OfflineWatchProgressItem?> getLatestWatchAction(String globalKey) {
return (select(offlineWatchProgress)
..where((t) => t.globalKey.equals(globalKey))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
..limit(1))
.getSingleOrNull();
}
/// 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) async {
if (globalKeys.isEmpty) return {};
// Query all actions for the given keys
final allActions =
await (select(offlineWatchProgress)
..where((t) => t.globalKey.isIn(globalKeys))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
// Group by globalKey and take the latest (first due to ordering)
final result = <String, OfflineWatchProgressItem>{};
for (final action in allActions) {
// Only keep the first (latest) action for each key
result.putIfAbsent(action.globalKey, () => action);
}
return result;
}
/// Insert or update a progress action (merges with existing)
Future<void> upsertProgressAction({
required String serverId,
required String ratingKey,
required int viewOffset,
required int duration,
required bool shouldMarkWatched,
}) async {
final globalKey = buildGlobalKey(serverId, ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
// Check for existing progress entry
final existing =
await (select(offlineWatchProgress)
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals(OfflineActionType.progress.name))
..limit(1))
.getSingleOrNull();
if (existing != null) {
// Update existing progress entry
await (update(offlineWatchProgress)..where((t) => t.id.equals(existing.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
updatedAt: Value(now),
),
);
} else {
// Insert new progress entry
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.name,
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({
required String serverId,
required String ratingKey,
required String actionType, // 'watched' or 'unwatched'
}) async {
final globalKey = buildGlobalKey(serverId, ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
// Remove conflicting actions (opposite action type and progress)
await (delete(offlineWatchProgress)..where((t) => t.globalKey.equals(globalKey))).go();
// Insert the new action
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
ratingKey: ratingKey,
globalKey: globalKey,
actionType: actionType,
createdAt: now,
updatedAt: now,
),
);
}
/// Delete a specific watch action after successful sync
Future<void> deleteWatchAction(int id) {
return (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go();
}
/// Update sync attempt count and error message
Future<void> updateSyncAttempt(int id, String? errorMessage) 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() async {
final count = await (selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]))
.map((row) => row.read(offlineWatchProgress.id.count()))
.getSingle();
return count ?? 0;
}
/// Clear all pending watch actions (e.g., after logout)
Future<void> clearAllWatchActions() {
return delete(offlineWatchProgress).go();
}
// ============================================================
// Sync Rules Operations
// ============================================================
Future<List<SyncRuleItem>> getSyncRules() {
return select(syncRules).get();
}
Future<SyncRuleItem?> getSyncRule(String globalKey) {
return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> insertSyncRule({
required String serverId,
required String ratingKey,
required String globalKey,
required String targetType,
required int episodeCount,
int mediaIndex = 0,
String downloadFilter = 'unwatched',
}) async {
await into(syncRules).insertOnConflictUpdate(
SyncRulesCompanion.insert(
serverId: serverId,
ratingKey: ratingKey,
globalKey: globalKey,
targetType: targetType,
episodeCount: episodeCount,
createdAt: DateTime.now().millisecondsSinceEpoch,
mediaIndex: Value(mediaIndex),
downloadFilter: Value(downloadFilter),
),
);
}
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
}
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
}
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
}
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
);
}
Future<void> deleteSyncRule(String globalKey) async {
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
}
// ============================================================
// Downloaded Media Queries for Watch State Sync
// ============================================================
/// 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();
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = (Platform.isAndroid || Platform.isIOS)
? await getApplicationDocumentsDirectory()
: await getApplicationSupportDirectory();
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
// Ensure directory exists
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
// Migrate from old location on desktop (was in Documents subfolder)
if (!Platform.isAndroid && !Platform.isIOS && !await file.exists()) {
final oldFolder = await getApplicationDocumentsDirectory();
final oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
if (await oldFile.exists()) {
await oldFile.rename(file.path);
}
}
return NativeDatabase.createInBackground(
file,
setup: (db) {
db.execute('PRAGMA journal_mode=WAL');
db.execute('PRAGMA synchronous=NORMAL');
},
);
});
}