feat: jellyfin
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/database/download_operations.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
|
||||
void main() {
|
||||
@@ -20,16 +21,76 @@ void main() {
|
||||
// ============================================================
|
||||
|
||||
group('schema', () {
|
||||
test('schemaVersion is 13', () {
|
||||
expect(db.schemaVersion, 13);
|
||||
test('schemaVersion is 14', () {
|
||||
expect(db.schemaVersion, 14);
|
||||
});
|
||||
|
||||
test('all tables are accessible and start empty', () async {
|
||||
expect(await db.select(db.downloadedMedia).get(), isEmpty);
|
||||
expect(await db.select(db.downloadOwners).get(), isEmpty);
|
||||
expect(await db.select(db.downloadQueue).get(), isEmpty);
|
||||
expect(await db.select(db.apiCache).get(), isEmpty);
|
||||
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
|
||||
expect(await db.select(db.syncRules).get(), isEmpty);
|
||||
expect(await db.select(db.connections).get(), isEmpty);
|
||||
expect(await db.select(db.profiles).get(), isEmpty);
|
||||
expect(await db.select(db.profileConnections).get(), isEmpty);
|
||||
});
|
||||
|
||||
test('ProfileConnections has no profile_id FK (virtual plex_home profiles)', () async {
|
||||
// v20 dropped the profile_id FK so virtual Plex Home profiles can
|
||||
// persist join rows without a parent `profiles` row. The two
|
||||
// profile-delete sites (profile_detail_screen, profile_switch_screen)
|
||||
// call ProfileConnectionRegistry.removeAllForProfile manually before
|
||||
// deleting the profile, so the cascade isn't needed.
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db
|
||||
.into(db.connections)
|
||||
.insert(
|
||||
ConnectionsCompanion.insert(id: 'c1', kind: 'plex', displayName: 'C1', configJson: '{}', createdAt: now),
|
||||
);
|
||||
// No `profiles` row inserted — this would have failed pre-v20.
|
||||
await db
|
||||
.into(db.profileConnections)
|
||||
.insert(
|
||||
ProfileConnectionsCompanion.insert(
|
||||
profileId: 'plex-home-c1-uuid',
|
||||
connectionId: 'c1',
|
||||
userIdentifier: 'uuid',
|
||||
),
|
||||
);
|
||||
expect(await db.select(db.profileConnections).get(), hasLength(1));
|
||||
});
|
||||
|
||||
test('ProfileConnections FK cascades when a Connection is deleted', () async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db
|
||||
.into(db.connections)
|
||||
.insert(
|
||||
ConnectionsCompanion.insert(id: 'c2', kind: 'plex', displayName: 'C2', configJson: '{}', createdAt: now),
|
||||
);
|
||||
await db
|
||||
.into(db.profiles)
|
||||
.insert(
|
||||
ProfilesCompanion.insert(id: 'p2', kind: 'local', displayName: 'P2', configJson: '{}', createdAt: now),
|
||||
);
|
||||
await db
|
||||
.into(db.profileConnections)
|
||||
.insert(ProfileConnectionsCompanion.insert(profileId: 'p2', connectionId: 'c2', userIdentifier: 'u2'));
|
||||
|
||||
await (db.delete(db.connections)..where((t) => t.id.equals('c2'))).go();
|
||||
expect(await db.select(db.profileConnections).get(), isEmpty);
|
||||
});
|
||||
|
||||
test('hot-query indices exist in sqlite_master', () async {
|
||||
// sqlite_master rows let us assert the indices physically exist
|
||||
// without depending on Drift's `Migrator` having run them.
|
||||
final rows = await db.customSelect("SELECT name FROM sqlite_master WHERE type = 'index'").get();
|
||||
final names = rows.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
names,
|
||||
containsAll(['idx_profiles_kind', 'idx_profile_connections_profile_id', 'idx_offline_watch_progress_server']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +165,7 @@ void main() {
|
||||
group('DownloadedMedia', () {
|
||||
Future<int> insertMovie({
|
||||
String serverId = 'srv1',
|
||||
String? clientScopeId,
|
||||
String ratingKey = '100',
|
||||
int status = 0, // queued
|
||||
int progress = 0,
|
||||
@@ -113,6 +175,7 @@ void main() {
|
||||
.insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: '$serverId:$ratingKey',
|
||||
type: 'movie',
|
||||
@@ -128,6 +191,7 @@ void main() {
|
||||
final rows = await db.select(db.downloadedMedia).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.serverId, 'srv1');
|
||||
expect(rows.first.clientScopeId, isNull);
|
||||
expect(rows.first.ratingKey, '100');
|
||||
expect(rows.first.globalKey, 'srv1:100');
|
||||
expect(rows.first.type, 'movie');
|
||||
@@ -140,6 +204,14 @@ void main() {
|
||||
expect(rows.first.totalBytes, isNull);
|
||||
});
|
||||
|
||||
test('clientScopeId is persisted for user-scoped downloads', () async {
|
||||
await insertMovie(serverId: 'jf-machine', clientScopeId: 'jf-machine/user-a');
|
||||
|
||||
final row = await db.select(db.downloadedMedia).getSingle();
|
||||
expect(row.serverId, 'jf-machine');
|
||||
expect(row.clientScopeId, 'jf-machine/user-a');
|
||||
});
|
||||
|
||||
test('updating progress field works', () async {
|
||||
await insertMovie();
|
||||
await (db.update(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).write(
|
||||
@@ -177,6 +249,33 @@ void main() {
|
||||
final completed = await db.getAllDownloadedMetadata();
|
||||
expect(completed.map((i) => i.ratingKey).toSet(), {'2', '4'});
|
||||
});
|
||||
|
||||
test('download owners keep profile visibility separate for one physical row', () async {
|
||||
await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index);
|
||||
|
||||
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1');
|
||||
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:1');
|
||||
|
||||
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'});
|
||||
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv1:1'});
|
||||
expect(await db.getDownloadOwnerCount('srv1:1'), 2);
|
||||
|
||||
await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1');
|
||||
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
|
||||
expect(await db.hasDownloadOwner('srv1:1'), isTrue);
|
||||
expect(await db.hasDownloadOwner('srv1:1', excludingProfileId: 'profile-b'), isFalse);
|
||||
});
|
||||
|
||||
test('adoptLegacyDownloadsForProfile claims only ownerless physical rows', () async {
|
||||
await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index);
|
||||
await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index);
|
||||
await db.addDownloadOwner(profileId: 'profile-existing', globalKey: 'srv1:2');
|
||||
|
||||
await db.adoptLegacyDownloadsForProfile('profile-a');
|
||||
|
||||
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'});
|
||||
expect(await db.getDownloadOwnerKeysForProfile('profile-existing'), {'srv1:2'});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -196,7 +295,8 @@ void main() {
|
||||
final rows = await db.select(db.offlineWatchProgress).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.globalKey, 'srv:42');
|
||||
expect(rows.first.actionType, OfflineActionType.progress.name);
|
||||
expect(rows.first.clientScopeId, isNull);
|
||||
expect(rows.first.actionType, OfflineActionType.progress.id);
|
||||
expect(rows.first.viewOffset, 5000);
|
||||
expect(rows.first.duration, 10000);
|
||||
expect(rows.first.shouldMarkWatched, isFalse);
|
||||
@@ -225,6 +325,32 @@ void main() {
|
||||
expect(rows.first.shouldMarkWatched, isTrue);
|
||||
});
|
||||
|
||||
test('upsertProgressAction keeps scoped Jellyfin users separate', () async {
|
||||
await db.upsertProgressAction(
|
||||
serverId: 'srv',
|
||||
clientScopeId: 'srv/user-a',
|
||||
ratingKey: '42',
|
||||
viewOffset: 1000,
|
||||
duration: 10000,
|
||||
shouldMarkWatched: false,
|
||||
);
|
||||
await db.upsertProgressAction(
|
||||
serverId: 'srv',
|
||||
clientScopeId: 'srv/user-b',
|
||||
ratingKey: '42',
|
||||
viewOffset: 9000,
|
||||
duration: 10000,
|
||||
shouldMarkWatched: true,
|
||||
);
|
||||
|
||||
final rows = await (db.select(
|
||||
db.offlineWatchProgress,
|
||||
)..orderBy([(t) => OrderingTerm.asc(t.clientScopeId)])).get();
|
||||
expect(rows, hasLength(2));
|
||||
expect(rows.map((r) => r.clientScopeId), ['srv/user-a', 'srv/user-b']);
|
||||
expect(rows.map((r) => r.viewOffset), [1000, 9000]);
|
||||
});
|
||||
|
||||
test('insertWatchAction (watched) clears prior progress + insert single row', () async {
|
||||
// Existing progress row for the same item
|
||||
await db.upsertProgressAction(
|
||||
@@ -235,14 +361,49 @@ void main() {
|
||||
shouldMarkWatched: false,
|
||||
);
|
||||
|
||||
await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.id);
|
||||
|
||||
final rows = await db.select(db.offlineWatchProgress).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.actionType, OfflineActionType.watched.name);
|
||||
expect(rows.first.actionType, OfflineActionType.watched.id);
|
||||
expect(rows.first.viewOffset, isNull);
|
||||
});
|
||||
|
||||
test('insertWatchAction clears only matching clientScopeId conflicts', () async {
|
||||
await db.upsertProgressAction(
|
||||
serverId: 'srv',
|
||||
clientScopeId: 'srv/user-a',
|
||||
ratingKey: '42',
|
||||
viewOffset: 1000,
|
||||
duration: 10000,
|
||||
shouldMarkWatched: false,
|
||||
);
|
||||
await db.upsertProgressAction(
|
||||
serverId: 'srv',
|
||||
clientScopeId: 'srv/user-b',
|
||||
ratingKey: '42',
|
||||
viewOffset: 2000,
|
||||
duration: 10000,
|
||||
shouldMarkWatched: false,
|
||||
);
|
||||
|
||||
await db.insertWatchAction(
|
||||
serverId: 'srv',
|
||||
clientScopeId: 'srv/user-a',
|
||||
ratingKey: '42',
|
||||
actionType: OfflineActionType.watched.id,
|
||||
);
|
||||
|
||||
final rows = await (db.select(
|
||||
db.offlineWatchProgress,
|
||||
)..orderBy([(t) => OrderingTerm.asc(t.clientScopeId), (t) => OrderingTerm.asc(t.actionType)])).get();
|
||||
expect(rows, hasLength(2));
|
||||
expect(rows.map((r) => (r.clientScopeId, r.actionType)).toList(), [
|
||||
('srv/user-a', OfflineActionType.watched.id),
|
||||
('srv/user-b', OfflineActionType.progress.id),
|
||||
]);
|
||||
});
|
||||
|
||||
test('getPendingWatchActions returns rows ordered by createdAt asc', () async {
|
||||
// Inject deterministic createdAt by raw inserts
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
@@ -253,7 +414,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
actionType: OfflineActionType.watched.id,
|
||||
createdAt: now + 100,
|
||||
updatedAt: now + 100,
|
||||
),
|
||||
@@ -265,7 +426,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '2',
|
||||
globalKey: 's:2',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
actionType: OfflineActionType.watched.id,
|
||||
createdAt: now + 50,
|
||||
updatedAt: now + 50,
|
||||
),
|
||||
@@ -275,10 +436,25 @@ void main() {
|
||||
expect(pending.map((p) => p.ratingKey).toList(), ['2', '1']);
|
||||
});
|
||||
|
||||
test('adoptLegacyOfflineWatchActionsForProfile claims null-profile rows', () async {
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
|
||||
await db.insertWatchAction(
|
||||
profileId: 'profile-existing',
|
||||
serverId: 's',
|
||||
ratingKey: '2',
|
||||
actionType: OfflineActionType.watched.id,
|
||||
);
|
||||
|
||||
await db.adoptLegacyOfflineWatchActionsForProfile('profile-a');
|
||||
|
||||
expect((await db.getPendingWatchActions(profileId: 'profile-a')).map((r) => r.ratingKey), ['1']);
|
||||
expect((await db.getPendingWatchActions(profileId: 'profile-existing')).map((r) => r.ratingKey), ['2']);
|
||||
});
|
||||
|
||||
test('getPendingWatchActionsForServer filters by serverId', () async {
|
||||
await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.name);
|
||||
await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.id);
|
||||
await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.id);
|
||||
await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.id);
|
||||
|
||||
final aRows = await db.getPendingWatchActionsForServer('a');
|
||||
expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'});
|
||||
@@ -296,7 +472,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.progress.name,
|
||||
actionType: OfflineActionType.progress.id,
|
||||
createdAt: now,
|
||||
updatedAt: now - 100,
|
||||
),
|
||||
@@ -308,7 +484,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
actionType: OfflineActionType.watched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now + 50,
|
||||
),
|
||||
@@ -316,7 +492,7 @@ void main() {
|
||||
|
||||
final latest = await db.getLatestWatchAction('s:1');
|
||||
expect(latest, isNotNull);
|
||||
expect(latest!.actionType, OfflineActionType.watched.name);
|
||||
expect(latest!.actionType, OfflineActionType.watched.id);
|
||||
});
|
||||
|
||||
test('getLatestWatchAction returns null when no rows', () async {
|
||||
@@ -332,7 +508,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.progress.name,
|
||||
actionType: OfflineActionType.progress.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
@@ -344,7 +520,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
actionType: OfflineActionType.watched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now + 100,
|
||||
),
|
||||
@@ -356,7 +532,7 @@ void main() {
|
||||
serverId: 's',
|
||||
ratingKey: '2',
|
||||
globalKey: 's:2',
|
||||
actionType: OfflineActionType.unwatched.name,
|
||||
actionType: OfflineActionType.unwatched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
@@ -364,8 +540,82 @@ void main() {
|
||||
|
||||
final result = await db.getLatestWatchActionsForKeys({'s:1', 's:2', 's:3-missing'});
|
||||
expect(result.keys.toSet(), {'s:1', 's:2'});
|
||||
expect(result['s:1']!.actionType, OfflineActionType.watched.name);
|
||||
expect(result['s:2']!.actionType, OfflineActionType.unwatched.name);
|
||||
expect(result['s:1']!.actionType, OfflineActionType.watched.id);
|
||||
expect(result['s:2']!.actionType, OfflineActionType.unwatched.id);
|
||||
});
|
||||
|
||||
test('getLatestWatchActionsForKeys filters by expected clientScopeId', () async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 'jf',
|
||||
clientScopeId: const Value('jf/user-a'),
|
||||
ratingKey: '1',
|
||||
globalKey: 'jf:1',
|
||||
actionType: OfflineActionType.unwatched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 'jf',
|
||||
clientScopeId: const Value('jf/user-b'),
|
||||
ratingKey: '1',
|
||||
globalKey: 'jf:1',
|
||||
actionType: OfflineActionType.watched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now + 100,
|
||||
),
|
||||
);
|
||||
|
||||
final userA = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-a'});
|
||||
final userB = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-b'});
|
||||
|
||||
expect(userA['jf:1']!.actionType, OfflineActionType.unwatched.id);
|
||||
expect(userB['jf:1']!.actionType, OfflineActionType.watched.id);
|
||||
});
|
||||
|
||||
test('getLatestWatchActionsForKeys filters by profile when requested', () async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 's',
|
||||
profileId: const Value('profile-a'),
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.unwatched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 's',
|
||||
profileId: const Value('profile-b'),
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.watched.id,
|
||||
createdAt: now,
|
||||
updatedAt: now + 100,
|
||||
),
|
||||
);
|
||||
|
||||
final profileA = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-a', filterProfile: true);
|
||||
final profileB = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-b', filterProfile: true);
|
||||
final globalLatest = await db.getLatestWatchActionsForKeys({'s:1'});
|
||||
|
||||
expect(profileA['s:1']!.actionType, OfflineActionType.unwatched.id);
|
||||
expect(profileB['s:1']!.actionType, OfflineActionType.watched.id);
|
||||
expect(globalLatest['s:1']!.actionType, OfflineActionType.watched.id);
|
||||
});
|
||||
|
||||
test('getLatestWatchActionsForKeys with empty input returns empty map (no query)', () async {
|
||||
@@ -373,7 +623,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('updateSyncAttempt increments syncAttempts and stores lastError', () async {
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
|
||||
final inserted = (await db.select(db.offlineWatchProgress).get()).single;
|
||||
|
||||
await db.updateSyncAttempt(inserted.id, 'boom');
|
||||
@@ -393,8 +643,8 @@ void main() {
|
||||
});
|
||||
|
||||
test('deleteWatchAction removes only the matching row', () async {
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.id);
|
||||
final rows = await db.select(db.offlineWatchProgress).get();
|
||||
expect(rows, hasLength(2));
|
||||
|
||||
@@ -405,14 +655,14 @@ void main() {
|
||||
test('getPendingSyncCount counts every row', () async {
|
||||
expect(await db.getPendingSyncCount(), 0);
|
||||
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id);
|
||||
expect(await db.getPendingSyncCount(), 2);
|
||||
});
|
||||
|
||||
test('clearAllWatchActions empties the table', () async {
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id);
|
||||
|
||||
await db.clearAllWatchActions();
|
||||
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
|
||||
@@ -436,6 +686,7 @@ void main() {
|
||||
final rules = await db.getSyncRules();
|
||||
expect(rules, hasLength(1));
|
||||
expect(rules.first.targetType, 'show');
|
||||
expect(rules.first.profileId, '');
|
||||
expect(rules.first.episodeCount, 5);
|
||||
expect(rules.first.enabled, isTrue); // default
|
||||
expect(rules.first.downloadFilter, 'unwatched'); // default
|
||||
@@ -443,10 +694,12 @@ void main() {
|
||||
expect(rules.first.lastExecutedAt, isNull);
|
||||
});
|
||||
|
||||
test('insertSyncRule with duplicate globalKey throws on the UNIQUE constraint', () async {
|
||||
// insertOnConflictUpdate only auto-targets the primary key (`id`), so a
|
||||
// collision on the UNIQUE `global_key` column still throws — this pins
|
||||
// current production behavior.
|
||||
test('insertSyncRule upserts on the UNIQUE globalKey instead of crashing', () async {
|
||||
// The auto-incremented primary key never collides, so a duplicate
|
||||
// globalKey would fail the UNIQUE constraint with a vanilla
|
||||
// `insertOnConflictUpdate`. The helper drives the upsert off
|
||||
// [globalKey] so re-creating a rule for the same target updates the
|
||||
// existing row rather than throwing.
|
||||
await db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
@@ -454,16 +707,69 @@ void main() {
|
||||
targetType: 'show',
|
||||
episodeCount: 5,
|
||||
);
|
||||
expect(
|
||||
() => db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'season',
|
||||
episodeCount: 99,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
await db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'season',
|
||||
episodeCount: 99,
|
||||
downloadFilter: 'all',
|
||||
);
|
||||
|
||||
final rules = await db.getSyncRules();
|
||||
expect(rules, hasLength(1));
|
||||
expect(rules.first.targetType, 'season');
|
||||
expect(rules.first.episodeCount, 99);
|
||||
expect(rules.first.downloadFilter, 'all');
|
||||
});
|
||||
|
||||
test('insertSyncRule allows the same server item for different profiles', () async {
|
||||
await db.insertSyncRule(
|
||||
profileId: 'profile-a',
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'profile-a|srv:10',
|
||||
targetType: 'show',
|
||||
episodeCount: 5,
|
||||
);
|
||||
await db.insertSyncRule(
|
||||
profileId: 'profile-b',
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'profile-b|srv:10',
|
||||
targetType: 'show',
|
||||
episodeCount: 9,
|
||||
);
|
||||
|
||||
expect(await db.getSyncRules(profileId: 'profile-a'), hasLength(1));
|
||||
expect((await db.getSyncRules(profileId: 'profile-a')).single.episodeCount, 5);
|
||||
expect(await db.getSyncRules(profileId: 'profile-b'), hasLength(1));
|
||||
expect((await db.getSyncRules(profileId: 'profile-b')).single.episodeCount, 9);
|
||||
});
|
||||
|
||||
test('insertSyncRule preserves enabled + lastExecutedAt across upserts', () async {
|
||||
await db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'show',
|
||||
episodeCount: 5,
|
||||
);
|
||||
await db.updateSyncRuleEnabled('srv:10', false);
|
||||
await db.updateSyncRuleLastExecuted('srv:10');
|
||||
final firstRun = (await db.getSyncRule('srv:10'))!;
|
||||
|
||||
await db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'show',
|
||||
episodeCount: 8,
|
||||
);
|
||||
final afterUpsert = (await db.getSyncRule('srv:10'))!;
|
||||
expect(afterUpsert.episodeCount, 8);
|
||||
expect(afterUpsert.enabled, isFalse, reason: 'upsert should preserve disabled flag');
|
||||
expect(afterUpsert.lastExecutedAt, firstRun.lastExecutedAt);
|
||||
});
|
||||
|
||||
test('getSyncRule returns the matching rule or null', () async {
|
||||
|
||||
@@ -365,6 +365,54 @@ void main() {
|
||||
expect(await db.getEpisodesBySeason('seasonZ'), isEmpty);
|
||||
});
|
||||
|
||||
test('getEpisodesBySeason can filter by server and client scope', () async {
|
||||
await db.insertDownload(
|
||||
serverId: 'jf',
|
||||
clientScopeId: 'jf/user-a',
|
||||
ratingKey: 'ep-a',
|
||||
globalKey: 'jf:ep-a',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'jf',
|
||||
clientScopeId: 'jf/user-b',
|
||||
ratingKey: 'ep-b',
|
||||
globalKey: 'jf:ep-b',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'other',
|
||||
ratingKey: 'ep-other',
|
||||
globalKey: 'other:ep-other',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'other',
|
||||
clientScopeId: 'other/user-a',
|
||||
ratingKey: 'ep-other-scoped',
|
||||
globalKey: 'other:ep-other-scoped',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
|
||||
final userA = await db.getEpisodesBySeason('season1', serverId: 'jf', clientScopeId: 'jf/user-a');
|
||||
final unscoped = await db.getEpisodesBySeason('season1', serverId: 'other', filterClientScope: true);
|
||||
|
||||
expect(userA.map((e) => e.ratingKey), ['ep-a']);
|
||||
expect(unscoped.map((e) => e.ratingKey), ['ep-other']);
|
||||
});
|
||||
|
||||
test('getEpisodesByShow filters by grandparentRatingKey', () async {
|
||||
await seedTree();
|
||||
|
||||
@@ -374,6 +422,33 @@ void main() {
|
||||
expect(await db.getEpisodesByShow('show-missing'), isEmpty);
|
||||
});
|
||||
|
||||
test('getEpisodesByShow can filter by server and client scope', () async {
|
||||
await db.insertDownload(
|
||||
serverId: 'jf',
|
||||
clientScopeId: 'jf/user-a',
|
||||
ratingKey: 'ep-a',
|
||||
globalKey: 'jf:ep-a',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'jf',
|
||||
clientScopeId: 'jf/user-b',
|
||||
ratingKey: 'ep-b',
|
||||
globalKey: 'jf:ep-b',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
|
||||
final userB = await db.getEpisodesByShow('show1', serverId: 'jf', clientScopeId: 'jf/user-b');
|
||||
|
||||
expect(userB.map((e) => e.ratingKey), ['ep-b']);
|
||||
});
|
||||
|
||||
test('getDownloadsByServerId filters by serverId', () async {
|
||||
await seedTree();
|
||||
|
||||
@@ -387,6 +462,50 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Download owners
|
||||
// ============================================================
|
||||
|
||||
group('download owners', () {
|
||||
Future<void> insertProfile(String id) async {
|
||||
await db
|
||||
.into(db.profiles)
|
||||
.insert(ProfilesCompanion.insert(id: id, kind: 'local', displayName: id, configJson: '{}', createdAt: 0));
|
||||
}
|
||||
|
||||
Future<void> insertPlexConnection(String id) async {
|
||||
await db
|
||||
.into(db.connections)
|
||||
.insert(ConnectionsCompanion.insert(id: id, kind: 'plex', displayName: id, configJson: '{}', createdAt: 0));
|
||||
}
|
||||
|
||||
test('owner counts ignore orphan local profiles', () async {
|
||||
await insertProfile('profile-a');
|
||||
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100');
|
||||
await db.addDownloadOwner(profileId: 'profile-deleted', globalKey: 'srv:100');
|
||||
|
||||
expect(await db.getDownloadOwnerCount('srv:100'), 1);
|
||||
expect(await db.hasDownloadOwner('srv:100', excludingProfileId: 'profile-a'), isFalse);
|
||||
});
|
||||
|
||||
test('owner counts preserve virtual Plex Home profile ids', () async {
|
||||
const plexHomeProfileId = 'plex-home-account-1-00000000-0000-0000-0000-000000000001';
|
||||
await insertPlexConnection('account-1');
|
||||
await db.addDownloadOwner(profileId: plexHomeProfileId, globalKey: 'srv:100');
|
||||
|
||||
expect(await db.getDownloadOwnerCount('srv:100'), 1);
|
||||
expect(await db.hasDownloadOwner('srv:100'), isTrue);
|
||||
});
|
||||
|
||||
test('owner counts ignore Plex Home rows whose parent connection is gone', () async {
|
||||
const plexHomeProfileId = 'plex-home-missing-account-00000000-0000-0000-0000-000000000001';
|
||||
await db.addDownloadOwner(profileId: plexHomeProfileId, globalKey: 'srv:100');
|
||||
|
||||
expect(await db.getDownloadOwnerCount('srv:100'), 0);
|
||||
expect(await db.hasDownloadOwner('srv:100'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// deleteDownload — removes from both tables
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user