feat: downloads

This commit is contained in:
edde746
2025-12-12 02:59:27 +01:00
parent b7c630f10e
commit dc44354036
65 changed files with 28245 additions and 6883 deletions
+208
View File
@@ -0,0 +1,208 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables/downloaded_media.dart';
import 'tables/download_queue.dart';
import 'tables/api_cache.dart';
import 'tables/offline_watch_progress.dart';
import '../models/download_status.dart';
import '../utils/app_logger.dart';
part 'app_database.g.dart';
// Simplified database with API cache for offline support
@DriftDatabase(
tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress],
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 7; // Added OfflineWatchProgress table
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Additive migration for schema version 7
if (from < 7) {
appLogger.i('Adding OfflineWatchProgress table (v7 migration)');
await m.createTable(offlineWatchProgress);
}
},
);
}
// ============================================================
// 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();
}
/// 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 = '$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('progress'),
)
..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: 'progress',
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 = '$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();
}
// ============================================================
// 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 = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
return NativeDatabase(file);
});
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
/// Key-value cache table for Plex API responses.
/// Used for offline support - stores raw JSON responses.
class ApiCache extends Table {
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345")
TextColumn get cacheKey => text()();
/// JSON response data
TextColumn get data => text()();
/// Whether this item is pinned for offline access
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
/// Timestamp for cache invalidation (optional future use)
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {cacheKey};
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:drift/drift.dart';
@DataClassName('DownloadQueueItem')
class DownloadQueue extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get mediaGlobalKey => text().unique()();
IntColumn get priority => integer().withDefault(const Constant(0))();
IntColumn get addedAt => integer()();
BoolColumn get downloadSubtitles =>
boolean().withDefault(const Constant(true))();
BoolColumn get downloadArtwork =>
boolean().withDefault(const Constant(true))();
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:drift/drift.dart';
@DataClassName('DownloadedMediaItem')
class DownloadedMedia extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text()();
TextColumn get ratingKey => text()();
TextColumn get globalKey => text().unique()();
TextColumn get type => text()();
TextColumn get parentRatingKey => text().nullable()();
TextColumn get grandparentRatingKey => text().nullable()();
IntColumn get status => integer()();
IntColumn get progress => integer().withDefault(const Constant(0))();
IntColumn get totalBytes => integer().nullable()();
IntColumn get downloadedBytes => integer().withDefault(const Constant(0))();
TextColumn get videoFilePath => text().nullable()();
TextColumn get thumbPath => text().nullable()();
IntColumn get downloadedAt => integer().nullable()();
TextColumn get errorMessage => text().nullable()();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
}
@@ -0,0 +1,46 @@
import 'package:drift/drift.dart';
/// Queue for offline watch progress and manual watch actions.
///
/// Stores watch progress updates and manual watch/unwatch actions
/// that need to be synced to the Plex server when back online.
@DataClassName('OfflineWatchProgressItem')
class OfflineWatchProgress extends Table {
/// Auto-incrementing primary key
IntColumn get id => integer().autoIncrement()();
/// Server ID this media belongs to
TextColumn get serverId => text()();
/// Rating key of the media item
TextColumn get ratingKey => text()();
/// Global key (serverId:ratingKey) for easy lookup
TextColumn get globalKey => text()();
/// Type of action: 'progress', 'watched', 'unwatched'
TextColumn get actionType => text()();
/// Current playback position in milliseconds (for 'progress' actions)
IntColumn get viewOffset => integer().nullable()();
/// Duration of the media in milliseconds (for calculating percentage)
IntColumn get duration => integer().nullable()();
/// Whether this item should be marked as watched (for progress sync)
/// Auto-set to true when viewOffset >= 90% of duration
BoolColumn get shouldMarkWatched =>
boolean().withDefault(const Constant(false))();
/// Timestamp when this action was recorded (milliseconds since epoch)
IntColumn get createdAt => integer()();
/// Timestamp when this action was last updated (for merging progress updates)
IntColumn get updatedAt => integer()();
/// Number of sync attempts (for retry logic)
IntColumn get syncAttempts => integer().withDefault(const Constant(0))();
/// Last sync error message
TextColumn get lastError => text().nullable()();
}