refactor: type server identifiers

This commit is contained in:
edde746
2026-06-01 11:06:03 +02:00
parent 7b59dac26b
commit 74b8dc4561
258 changed files with 2296 additions and 2080 deletions
+43 -38
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:plezy/media/ids.dart';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
@@ -436,7 +437,7 @@ class _AppDatabaseTestSuite {
group('OfflineWatchProgress', () {
test('upsertProgressAction inserts a new progress row', () async {
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 5000,
duration: 10000,
@@ -456,14 +457,14 @@ class _AppDatabaseTestSuite {
test('upsertProgressAction merges into the existing progress row', () async {
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 1000,
duration: 10000,
shouldMarkWatched: false,
);
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 9500,
duration: 10000,
@@ -478,7 +479,7 @@ class _AppDatabaseTestSuite {
test('upsertProgressAction keeps scoped Jellyfin users separate', () async {
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: '42',
viewOffset: 1000,
@@ -486,7 +487,7 @@ class _AppDatabaseTestSuite {
shouldMarkWatched: false,
);
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
clientScopeId: 'srv/user-b',
ratingKey: '42',
viewOffset: 9000,
@@ -505,14 +506,18 @@ class _AppDatabaseTestSuite {
test('insertWatchAction (watched) clears prior progress + insert single row', () async {
// Existing progress row for the same item
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 5000,
duration: 10000,
shouldMarkWatched: false,
);
await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(
serverId: ServerId('srv'),
ratingKey: '42',
actionType: OfflineActionType.watched.id,
);
final rows = await db.select(db.offlineWatchProgress).get();
expect(rows, hasLength(1));
@@ -522,7 +527,7 @@ class _AppDatabaseTestSuite {
test('insertWatchAction clears only matching clientScopeId conflicts', () async {
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: '42',
viewOffset: 1000,
@@ -530,7 +535,7 @@ class _AppDatabaseTestSuite {
shouldMarkWatched: false,
);
await db.upsertProgressAction(
serverId: 'srv',
serverId: ServerId('srv'),
clientScopeId: 'srv/user-b',
ratingKey: '42',
viewOffset: 2000,
@@ -539,7 +544,7 @@ class _AppDatabaseTestSuite {
);
await db.insertWatchAction(
serverId: 'srv',
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: '42',
actionType: OfflineActionType.watched.id,
@@ -588,10 +593,10 @@ class _AppDatabaseTestSuite {
});
test('adoptLegacyOfflineWatchActionsForProfile claims null-profile rows', () async {
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(
profileId: 'profile-existing',
serverId: 's',
serverId: ServerId('s'),
ratingKey: '2',
actionType: OfflineActionType.watched.id,
);
@@ -603,14 +608,14 @@ class _AppDatabaseTestSuite {
});
test('getPendingWatchActionsForServer filters by serverId', () async {
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);
await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('b'), ratingKey: '2', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '3', actionType: OfflineActionType.unwatched.id);
final aRows = await db.getPendingWatchActionsForServer('a');
final aRows = await db.getPendingWatchActionsForServer(ServerId('a'));
expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'});
final bRows = await db.getPendingWatchActionsForServer('b');
final bRows = await db.getPendingWatchActionsForServer(ServerId('b'));
expect(bRows.map((r) => r.ratingKey).toSet(), {'2'});
});
@@ -774,7 +779,7 @@ class _AppDatabaseTestSuite {
});
test('updateSyncAttempt increments syncAttempts and stores lastError', () async {
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
final inserted = (await db.select(db.offlineWatchProgress).get()).single;
await db.updateSyncAttempt(inserted.id, 'boom');
@@ -794,8 +799,8 @@ class _AppDatabaseTestSuite {
});
test('deleteWatchAction removes only the matching row', () async {
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.watched.id);
final rows = await db.select(db.offlineWatchProgress).get();
expect(rows, hasLength(2));
@@ -806,14 +811,14 @@ class _AppDatabaseTestSuite {
test('getPendingSyncCount counts every row', () async {
expect(await db.getPendingSyncCount(), 0);
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: 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.id);
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id);
await db.clearAllWatchActions();
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
@@ -829,7 +834,7 @@ class _AppDatabaseTestSuite {
group('SyncRules', () {
test('insertSyncRule + getSyncRules round-trip with defaults', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -854,14 +859,14 @@ class _AppDatabaseTestSuite {
// [globalKey] so re-creating a rule for the same target updates the
// existing row rather than throwing.
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'season',
@@ -879,7 +884,7 @@ class _AppDatabaseTestSuite {
test('insertSyncRule allows the same server item for different profiles', () async {
await db.insertSyncRule(
profileId: 'profile-a',
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'profile-a|srv:10',
targetType: 'show',
@@ -887,7 +892,7 @@ class _AppDatabaseTestSuite {
);
await db.insertSyncRule(
profileId: 'profile-b',
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'profile-b|srv:10',
targetType: 'show',
@@ -902,7 +907,7 @@ class _AppDatabaseTestSuite {
test('insertSyncRule preserves enabled + lastExecutedAt across upserts', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -913,7 +918,7 @@ class _AppDatabaseTestSuite {
final firstRun = (await db.getSyncRule('srv:10'))!;
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -927,7 +932,7 @@ class _AppDatabaseTestSuite {
test('getSyncRule returns the matching rule or null', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -939,7 +944,7 @@ class _AppDatabaseTestSuite {
test('updateSyncRuleCount mutates only the count', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -954,7 +959,7 @@ class _AppDatabaseTestSuite {
test('updateSyncRuleFilter mutates the filter', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -968,7 +973,7 @@ class _AppDatabaseTestSuite {
test('updateSyncRuleEnabled toggles enabled', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -983,7 +988,7 @@ class _AppDatabaseTestSuite {
test('updateSyncRuleLastExecuted writes a timestamp', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
@@ -1001,14 +1006,14 @@ class _AppDatabaseTestSuite {
test('deleteSyncRule removes the matching row', () async {
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.insertSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '11',
globalKey: 'srv:11',
targetType: 'show',
+29 -28
View File
@@ -1,4 +1,5 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
@@ -23,7 +24,7 @@ void main() {
group('insertDownload', () {
test('inserts a movie row with defaults', () async {
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '100',
globalKey: 'srv:100',
type: 'movie',
@@ -45,7 +46,7 @@ void main() {
test('inserts an episode with parent and grandparent keys', () async {
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'ep1',
globalKey: 'srv:ep1',
type: 'episode',
@@ -63,7 +64,7 @@ void main() {
test('insertDownload uses InsertMode.insertOrReplace (re-insert overwrites)', () async {
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '100',
globalKey: 'srv:100',
type: 'movie',
@@ -74,7 +75,7 @@ void main() {
// Re-insert with the same globalKey — should replace, resetting progress to default 0.
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '100',
globalKey: 'srv:100',
type: 'movie',
@@ -140,14 +141,14 @@ void main() {
test('getNextQueueItem only returns items whose media is queued', () async {
// Two items in queue; one's media is still queued, the other is downloading.
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '1',
globalKey: 'srv:1',
type: 'movie',
status: DownloadStatus.queued.index,
);
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '2',
globalKey: 'srv:2',
type: 'movie',
@@ -166,21 +167,21 @@ void main() {
test('getNextQueueItem orders by priority desc, then addedAt asc', () async {
// All have queued status
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '1',
globalKey: 'srv:1',
type: 'movie',
status: DownloadStatus.queued.index,
);
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '2',
globalKey: 'srv:2',
type: 'movie',
status: DownloadStatus.queued.index,
);
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '3',
globalKey: 'srv:3',
type: 'movie',
@@ -212,7 +213,7 @@ void main() {
group('update helpers', () {
Future<void> seed({String key = 'srv:100'}) async {
await db.insertDownload(
serverId: key.split(':').first,
serverId: ServerId(key.split(':').first),
ratingKey: key.split(':').last,
globalKey: key,
type: 'movie',
@@ -308,7 +309,7 @@ void main() {
group('lookup helpers', () {
Future<void> seedTree() async {
await db.insertDownload(
serverId: 'srvA',
serverId: ServerId('srvA'),
ratingKey: 'ep1',
globalKey: 'srvA:ep1',
type: 'episode',
@@ -317,7 +318,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'srvA',
serverId: ServerId('srvA'),
ratingKey: 'ep2',
globalKey: 'srvA:ep2',
type: 'episode',
@@ -326,7 +327,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'srvA',
serverId: ServerId('srvA'),
ratingKey: 'ep3',
globalKey: 'srvA:ep3',
type: 'episode',
@@ -335,7 +336,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'srvB',
serverId: ServerId('srvB'),
ratingKey: 'movie1',
globalKey: 'srvB:movie1',
type: 'movie',
@@ -367,7 +368,7 @@ void main() {
test('getEpisodesBySeason can filter by server and client scope', () async {
await db.insertDownload(
serverId: 'jf',
serverId: ServerId('jf'),
clientScopeId: 'jf/user-a',
ratingKey: 'ep-a',
globalKey: 'jf:ep-a',
@@ -377,7 +378,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'jf',
serverId: ServerId('jf'),
clientScopeId: 'jf/user-b',
ratingKey: 'ep-b',
globalKey: 'jf:ep-b',
@@ -387,7 +388,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'other',
serverId: ServerId('other'),
ratingKey: 'ep-other',
globalKey: 'other:ep-other',
type: 'episode',
@@ -396,7 +397,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'other',
serverId: ServerId('other'),
clientScopeId: 'other/user-a',
ratingKey: 'ep-other-scoped',
globalKey: 'other:ep-other-scoped',
@@ -406,8 +407,8 @@ void main() {
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);
final userA = await db.getEpisodesBySeason('season1', serverId: ServerId('jf'), clientScopeId: 'jf/user-a');
final unscoped = await db.getEpisodesBySeason('season1', serverId: ServerId('other'), filterClientScope: true);
expect(userA.map((e) => e.ratingKey), ['ep-a']);
expect(unscoped.map((e) => e.ratingKey), ['ep-other']);
@@ -424,7 +425,7 @@ void main() {
test('getEpisodesByShow can filter by server and client scope', () async {
await db.insertDownload(
serverId: 'jf',
serverId: ServerId('jf'),
clientScopeId: 'jf/user-a',
ratingKey: 'ep-a',
globalKey: 'jf:ep-a',
@@ -434,7 +435,7 @@ void main() {
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: 'jf',
serverId: ServerId('jf'),
clientScopeId: 'jf/user-b',
ratingKey: 'ep-b',
globalKey: 'jf:ep-b',
@@ -444,7 +445,7 @@ void main() {
status: DownloadStatus.completed.index,
);
final userB = await db.getEpisodesByShow('show1', serverId: 'jf', clientScopeId: 'jf/user-b');
final userB = await db.getEpisodesByShow('show1', serverId: ServerId('jf'), clientScopeId: 'jf/user-b');
expect(userB.map((e) => e.ratingKey), ['ep-b']);
});
@@ -452,13 +453,13 @@ void main() {
test('getDownloadsByServerId filters by serverId', () async {
await seedTree();
final a = await db.getDownloadsByServerId('srvA');
final a = await db.getDownloadsByServerId(ServerId('srvA'));
expect(a.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2', 'ep3'});
final b = await db.getDownloadsByServerId('srvB');
final b = await db.getDownloadsByServerId(ServerId('srvB'));
expect(b.map((e) => e.ratingKey).toSet(), {'movie1'});
expect(await db.getDownloadsByServerId('srvZ'), isEmpty);
expect(await db.getDownloadsByServerId(ServerId('srvZ')), isEmpty);
});
});
@@ -513,7 +514,7 @@ void main() {
group('deleteDownload', () {
test('removes the row from downloadedMedia AND its queue entry', () async {
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '100',
globalKey: 'srv:100',
type: 'movie',
@@ -521,7 +522,7 @@ void main() {
);
await db.addToQueue(mediaGlobalKey: 'srv:100');
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '200',
globalKey: 'srv:200',
type: 'movie',
+12 -11
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mixins/deletion_aware.dart';
import 'package:plezy/utils/deletion_notifier.dart';
@@ -50,7 +51,7 @@ class _ProbeState extends State<_Probe> with DeletionAware {
}
DeletionEvent _ev({
required String serverId,
required ServerId serverId,
required String itemId,
List<String> parentChain = const [],
String mediaType = 'movie',
@@ -66,7 +67,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'}));
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await _settle(tester);
expect(state.events, hasLength(1));
@@ -77,7 +78,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'}));
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '999'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '999'));
await _settle(tester);
expect(state.events, isEmpty);
@@ -88,7 +89,7 @@ void main() {
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'show123'}));
DeletionNotifier().notify(
_ev(serverId: 's1', itemId: 'season789', parentChain: const ['show123'], mediaType: 'season'),
_ev(serverId: ServerId('s1'), itemId: 'season789', parentChain: const ['show123'], mediaType: 'season'),
);
await _settle(tester);
@@ -100,11 +101,11 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, serverIdOverride: 's1', itemIdsOverride: const {'42'}));
DeletionNotifier().notify(_ev(serverId: 's2', itemId: '42'));
DeletionNotifier().notify(_ev(serverId: ServerId('s2'), itemId: '42'));
await _settle(tester);
expect(state.events, isEmpty);
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await _settle(tester);
expect(state.events, hasLength(1));
});
@@ -115,11 +116,11 @@ void main() {
_Probe(onState: (s) => state = s, globalKeysOverride: const {'s1:99'}, itemIdsOverride: const {'5'}),
);
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '5'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '5'));
await _settle(tester);
expect(state.events, isEmpty);
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '99'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '99'));
await _settle(tester);
expect(state.events, hasLength(1));
expect(state.events.first.itemId, '99');
@@ -129,7 +130,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const <String>{}));
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '1'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '1'));
await _settle(tester);
expect(state.events, isEmpty);
@@ -139,13 +140,13 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'}));
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await _settle(tester);
expect(state.events, hasLength(1));
await tester.pumpWidget(const SizedBox.shrink());
DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42'));
DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await tester.pump(Duration.zero);
expect(state.events, hasLength(1));
+26 -25
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mixins/event_aware.dart';
@@ -10,7 +11,7 @@ class _FakeEvent with HierarchicalEventMixin {
_FakeEvent({required this.serverId, required this.itemId, this.parentChain = const []});
@override
final String serverId;
final ServerId serverId;
@override
final String itemId;
@@ -49,7 +50,7 @@ void main() {
onEvent: received.add,
);
final ev = _FakeEvent(serverId: 's1', itemId: '42');
final ev = _FakeEvent(serverId: ServerId('s1'), itemId: '42');
notifier.notify(ev);
await _settle();
@@ -68,13 +69,13 @@ void main() {
onEvent: received.add,
);
notifier.notify(_FakeEvent(serverId: 's1', itemId: '42'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '42'));
await _settle();
expect(received, isEmpty);
// Once mounted, future events flow.
mounted = true;
final ev = _FakeEvent(serverId: 's1', itemId: '99');
final ev = _FakeEvent(serverId: ServerId('s1'), itemId: '99');
notifier.notify(ev);
await _settle();
expect(received, [ev]);
@@ -92,8 +93,8 @@ void main() {
onEvent: received.add,
);
final keep = _FakeEvent(serverId: 's1', itemId: '1');
final drop = _FakeEvent(serverId: 's2', itemId: '1');
final keep = _FakeEvent(serverId: ServerId('s1'), itemId: '1');
final drop = _FakeEvent(serverId: ServerId('s2'), itemId: '1');
notifier.notify(drop);
notifier.notify(keep);
await _settle();
@@ -103,7 +104,7 @@ void main() {
});
test('globalKeys filter delivers events matching any global key', () async {
final keys = {buildGlobalKey('s1', '42'), buildGlobalKey('s1', '7')};
final keys = {buildGlobalKey(ServerId('s1'), '42'), buildGlobalKey(ServerId('s1'), '7')};
final sub = subscribeToHierarchicalEvents<_FakeEvent>(
notifier: notifier,
mounted: () => true,
@@ -113,8 +114,8 @@ void main() {
onEvent: received.add,
);
final hit = _FakeEvent(serverId: 's1', itemId: '42');
final miss = _FakeEvent(serverId: 's1', itemId: '9999');
final hit = _FakeEvent(serverId: ServerId('s1'), itemId: '42');
final miss = _FakeEvent(serverId: ServerId('s1'), itemId: '9999');
notifier.notify(hit);
notifier.notify(miss);
await _settle();
@@ -126,7 +127,7 @@ void main() {
test('globalKeys filter takes precedence over itemIds', () async {
// Even though itemIds would match '5', globalKeys path returns early
// and short-circuits the itemIds check.
final globalKeys = {buildGlobalKey('s1', '99')};
final globalKeys = {buildGlobalKey(ServerId('s1'), '99')};
final itemIds = {'5'};
final sub = subscribeToHierarchicalEvents<_FakeEvent>(
notifier: notifier,
@@ -138,12 +139,12 @@ void main() {
);
// itemId 5 matches the itemIds set but not the globalKeys set.
notifier.notify(_FakeEvent(serverId: 's1', itemId: '5'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '5'));
await _settle();
expect(received, isEmpty);
// Now an event matching the globalKeys set comes through.
final hit = _FakeEvent(serverId: 's1', itemId: '99');
final hit = _FakeEvent(serverId: ServerId('s1'), itemId: '99');
notifier.notify(hit);
await _settle();
expect(received, [hit]);
@@ -161,8 +162,8 @@ void main() {
onEvent: received.add,
);
final a = _FakeEvent(serverId: 's1', itemId: '1');
final b = _FakeEvent(serverId: 's2', itemId: '2');
final a = _FakeEvent(serverId: ServerId('s1'), itemId: '1');
final b = _FakeEvent(serverId: ServerId('s2'), itemId: '2');
notifier.notify(a);
notifier.notify(b);
await _settle();
@@ -181,8 +182,8 @@ void main() {
onEvent: received.add,
);
notifier.notify(_FakeEvent(serverId: 's1', itemId: '1'));
notifier.notify(_FakeEvent(serverId: 's2', itemId: '2'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1'));
notifier.notify(_FakeEvent(serverId: ServerId('s2'), itemId: '2'));
await _settle();
expect(received, isEmpty);
@@ -199,8 +200,8 @@ void main() {
onEvent: received.add,
);
final hit = _FakeEvent(serverId: 's1', itemId: '42');
final miss = _FakeEvent(serverId: 's1', itemId: '99');
final hit = _FakeEvent(serverId: ServerId('s1'), itemId: '42');
final miss = _FakeEvent(serverId: ServerId('s1'), itemId: '99');
notifier.notify(hit);
notifier.notify(miss);
await _settle();
@@ -221,7 +222,7 @@ void main() {
onEvent: received.add,
);
final episode = _FakeEvent(serverId: 's1', itemId: 'episode456', parentChain: ['season789', 'show123']);
final episode = _FakeEvent(serverId: ServerId('s1'), itemId: 'episode456', parentChain: ['season789', 'show123']);
notifier.notify(episode);
await _settle();
@@ -240,15 +241,15 @@ void main() {
onEvent: received.add,
);
notifier.notify(_FakeEvent(serverId: 's1', itemId: '1'));
notifier.notify(_FakeEvent(serverId: 's1', itemId: '2'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '2'));
await _settle();
expect(received.map((e) => e.itemId).toList(), ['1']);
// Change the filter set; the next event should be evaluated against it.
ids = {'2'};
notifier.notify(_FakeEvent(serverId: 's1', itemId: '1'));
notifier.notify(_FakeEvent(serverId: 's1', itemId: '2'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '2'));
await _settle();
expect(received.map((e) => e.itemId).toList(), ['1', '2']);
@@ -265,12 +266,12 @@ void main() {
onEvent: received.add,
);
notifier.notify(_FakeEvent(serverId: 's1', itemId: '1'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1'));
await _settle();
expect(received, hasLength(1));
await sub.cancel();
notifier.notify(_FakeEvent(serverId: 's1', itemId: '2'));
notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '2'));
await _settle();
expect(received, hasLength(1));
});
+4 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
@@ -51,7 +52,7 @@ class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> {
}
}
MediaLibrary _lib({String? serverId, String key = '1'}) =>
MediaLibrary _lib({ServerId? serverId, String key = '1'}) =>
MediaLibrary(id: key, backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie, serverId: serverId);
void main() {
@@ -60,7 +61,7 @@ void main() {
group('LibraryTabStateMixin', () {
testWidgets('library getter returns the host state\'s library', (tester) async {
late _ProbeState state;
final library = _lib(serverId: 'srv-A', key: 'lib-1');
final library = _lib(serverId: ServerId('srv-A'), key: 'lib-1');
await tester.pumpWidget(_Probe(library: library, onState: (s, _) => state = s));
await tester.pump();
@@ -85,7 +86,7 @@ void main() {
ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: _Probe(
library: _lib(serverId: 'srv-missing'),
library: _lib(serverId: ServerId('srv-missing')),
onState: (s, c) {
state = s;
ctx = c;
@@ -1,4 +1,5 @@
import 'package:flutter/widgets.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -36,7 +37,7 @@ class _ProbeState extends State<_Probe> with ServerBoundMediaMixin<_Probe> {
}
}
MediaItem _meta({String? serverId, String ratingKey = 'rk1'}) =>
MediaItem _meta({ServerId? serverId, String ratingKey = 'rk1'}) =>
MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId);
void main() {
@@ -47,7 +48,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
metadata: _meta(serverId: ServerId('srv-A')),
offline: false,
onState: (s, _) => state = s,
),
@@ -68,7 +69,7 @@ void main() {
late _ProbeState offState;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 's1'),
metadata: _meta(serverId: ServerId('s1')),
offline: false,
onState: (s, _) => offState = s,
),
@@ -78,7 +79,7 @@ void main() {
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 's1'),
metadata: _meta(serverId: ServerId('s1')),
offline: true,
onState: (s, _) => onState = s,
),
@@ -91,7 +92,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
metadata: _meta(serverId: ServerId('srv-A')),
offline: false,
onState: (s, _) => state = s,
),
@@ -106,7 +107,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
metadata: _meta(serverId: ServerId('srv-A')),
offline: false,
onState: (s, _) => state = s,
),
@@ -114,7 +115,7 @@ void main() {
await tester.pump();
// Explicit serverId takes precedence over the metadata-bound one.
expect(state.toServerBoundGlobalKey('rk-1', serverId: 'srv-B'), 'srv-B:rk-1');
expect(state.toServerBoundGlobalKey('rk-1', serverId: ServerId('srv-B')), 'srv-B:rk-1');
});
testWidgets('toServerBoundGlobalKey falls back to empty serverId when metadata has none', (tester) async {
@@ -131,7 +132,7 @@ void main() {
late BuildContext ctx;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
metadata: _meta(serverId: ServerId('srv-A')),
offline: true,
onState: (s, c) {
state = s;
+13 -12
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mixins/watch_state_aware.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
@@ -52,7 +53,7 @@ class _ProbeState extends State<_Probe> with WatchStateAware {
}
WatchStateEvent _ev({
required String serverId,
required ServerId serverId,
required String itemId,
List<String> parentChain = const [],
WatchStateChangeType type = WatchStateChangeType.watched,
@@ -70,7 +71,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'}));
final hit = _ev(serverId: 's1', itemId: '42');
final hit = _ev(serverId: ServerId('s1'), itemId: '42');
WatchStateNotifier().notify(hit);
await _settle(tester);
@@ -82,7 +83,7 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'}));
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '999'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '999'));
await _settle(tester);
expect(state.events, isEmpty);
@@ -94,7 +95,7 @@ void main() {
// Episode whose parent chain contains the show this screen tracks.
WatchStateNotifier().notify(
_ev(serverId: 's1', itemId: 'episode456', parentChain: const ['season789', 'show123']),
_ev(serverId: ServerId('s1'), itemId: 'episode456', parentChain: const ['season789', 'show123']),
);
await _settle(tester);
@@ -106,11 +107,11 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, serverIdOverride: 's1', itemIdsOverride: const {'42'}));
WatchStateNotifier().notify(_ev(serverId: 's2', itemId: '42'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s2'), itemId: '42'));
await _settle(tester);
expect(state.events, isEmpty);
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await _settle(tester);
expect(state.events, hasLength(1));
});
@@ -122,11 +123,11 @@ void main() {
);
// itemId 5 matches the itemIds set, but globalKeys is the active filter.
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '5'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '5'));
await _settle(tester);
expect(state.events, isEmpty);
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '99'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '99'));
await _settle(tester);
expect(state.events, hasLength(1));
expect(state.events.first.itemId, '99');
@@ -136,8 +137,8 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const <String>{}));
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '1'));
WatchStateNotifier().notify(_ev(serverId: 's2', itemId: '2'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '1'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s2'), itemId: '2'));
await _settle(tester);
expect(state.events, isEmpty);
@@ -147,14 +148,14 @@ void main() {
late _ProbeState state;
await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'}));
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await _settle(tester);
expect(state.events, hasLength(1));
// Replace the tree to dispose the probe.
await tester.pumpWidget(const SizedBox.shrink());
WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42'));
WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42'));
await tester.pump(Duration.zero);
// No second delivery — subscription cancelled.
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'dart:convert';
import 'package:drift/native.dart';
@@ -471,7 +472,7 @@ class _FailingPlexMultiServerManager extends MultiServerManager {
}) async {
refreshCalls++;
for (final server in connection.servers) {
updateServerStatus(server.clientIdentifier, false);
updateServerStatus(ServerId(server.clientIdentifier), false);
}
return const {};
}
@@ -490,7 +491,7 @@ class _BlockingMixedMultiServerManager extends MultiServerManager {
if (!plexStarted.isCompleted) plexStarted.complete();
await releasePlex.future;
for (final server in connection.servers) {
updateServerStatus(server.clientIdentifier, false);
updateServerStatus(ServerId(server.clientIdentifier), false);
}
return const {};
}
@@ -498,7 +499,7 @@ class _BlockingMixedMultiServerManager extends MultiServerManager {
@override
Future<bool> addJellyfinConnection(JellyfinConnection connection) async {
if (!jellyfinStarted.isCompleted) jellyfinStarted.complete();
updateServerStatus(connection.serverMachineId, true);
updateServerStatus(ServerId(connection.serverMachineId), true);
return true;
}
}
+42 -41
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -36,7 +37,7 @@ class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient {
_ScopedTestClient({required this.serverId, required this.scopedServerId});
@override
final String serverId;
final ServerId serverId;
@override
final String scopedServerId;
@@ -129,7 +130,7 @@ void main() {
test('falls back to media index when caller has no source id', () async {
const globalKey = 'srv:movie-1';
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'movie-1',
globalKey: globalKey,
type: 'movie',
@@ -158,8 +159,8 @@ void main() {
var notified = 0;
p.addListener(() => notified++);
await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor('srv', '10');
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
expect(p.hasSyncRule(ruleKey), isTrue);
final rule = p.getSyncRule(ruleKey);
@@ -184,8 +185,8 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor('srv', '10');
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
var notified = 0;
p.addListener(() => notified++);
@@ -202,8 +203,8 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'collection', episodeCount: 0);
final ruleKey = p.syncRuleKeyFor('srv', '10');
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'collection', episodeCount: 0);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
var notified = 0;
p.addListener(() => notified++);
@@ -219,8 +220,8 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor('srv', '10');
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
expect(p.getSyncRule(ruleKey)!.enabled, isTrue);
await p.setSyncRuleEnabled(ruleKey, false);
@@ -237,10 +238,10 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5);
await p.createSyncRule(serverId: 'srv', ratingKey: '11', targetType: 'show', episodeCount: 5);
final ruleKey10 = p.syncRuleKeyFor('srv', '10');
final ruleKey11 = p.syncRuleKeyFor('srv', '11');
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '11', targetType: 'show', episodeCount: 5);
final ruleKey10 = p.syncRuleKeyFor(ServerId('srv'), '10');
final ruleKey11 = p.syncRuleKeyFor(ServerId('srv'), '11');
expect(p.syncRules, hasLength(2));
var notified = 0;
@@ -267,10 +268,10 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.collection,
title: 'My Collection',
serverId: 'srv',
serverId: ServerId('srv'),
);
await p.createSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '20',
targetType: 'collection',
episodeCount: 0,
@@ -278,7 +279,7 @@ void main() {
);
expect(p.getMetadata('srv:20'), isNotNull, reason: 'targetMetadata should be stashed');
await p.deleteSyncRule(p.syncRuleKeyFor('srv', '20'));
await p.deleteSyncRule(p.syncRuleKeyFor(ServerId('srv'), '20'));
expect(p.getMetadata('srv:20'), isNull, reason: 'orphan metadata should be released');
p.dispose();
@@ -293,10 +294,10 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'A Show',
serverId: 'srv',
serverId: ServerId('srv'),
);
await p.createSyncRule(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '30',
targetType: 'show',
episodeCount: 5,
@@ -309,7 +310,7 @@ void main() {
downloads: {'srv:30': const DownloadProgress(globalKey: 'srv:30', status: DownloadStatus.queued)},
);
await p.deleteSyncRule(p.syncRuleKeyFor('srv', '30'));
await p.deleteSyncRule(p.syncRuleKeyFor(ServerId('srv'), '30'));
expect(p.getMetadata('srv:30'), isNotNull, reason: 'metadata is still in use by the download');
p.dispose();
@@ -322,7 +323,7 @@ void main() {
final keys = p.syncRuleKeysForWatchEvent(
WatchStateEvent(
itemId: 'episode-1',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
cacheServerId: 'jf-machine/user-a',
changeType: WatchStateChangeType.watched,
parentChain: const ['season-1', 'show-1'],
@@ -348,7 +349,7 @@ void main() {
// Pre-seed the database with a rule before the provider exists.
await db.insertSyncRule(
profileId: 'test-profile',
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '99',
globalKey: 'test-profile|srv:99',
targetType: 'show',
@@ -371,7 +372,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Owned Movie',
serverId: 'srv',
serverId: ServerId('srv'),
);
test('queueDownload is a no-op when downloads are unsupported', () async {
@@ -383,7 +384,7 @@ void main() {
final p = DownloadProvider.forTesting(downloadManager: unsupportedManager, database: db);
await p.ensureInitialized();
final queued = await p.queueDownload(movie, _ScopedTestClient(serverId: 'srv', scopedServerId: 'srv'));
final queued = await p.queueDownload(movie, _ScopedTestClient(serverId: ServerId('srv'), scopedServerId: 'srv'));
expect(queued, 0);
expect(p.downloads, isEmpty);
@@ -478,7 +479,7 @@ void main() {
test('deleteDownload is a no-op for unowned physical rows', () async {
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '1',
globalKey: 'srv:1',
type: 'movie',
@@ -503,7 +504,7 @@ void main() {
test('cancelDownload is a no-op for unowned physical rows', () async {
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: '1',
globalKey: 'srv:1',
type: 'movie',
@@ -539,7 +540,7 @@ void main() {
},
metadata: {
'srv:1': movie,
'other:2': movie.copyWith(id: '2', serverId: 'other'),
'other:2': movie.copyWith(id: '2', serverId: ServerId('other')),
},
);
@@ -577,8 +578,8 @@ void main() {
}
Future<void> putPinnedItem(String scopeId, String userId, String itemId, Map<String, Object?> data) async {
await JellyfinApiCache.instance.put(scopeId, '/Users/$userId/Items/$itemId', data);
await JellyfinApiCache.instance.pinForOffline(scopeId, itemId);
await JellyfinApiCache.instance.put(ServerId(scopeId), '/Users/$userId/Items/$itemId', data);
await JellyfinApiCache.instance.pinForOffline(ServerId(scopeId), itemId);
}
test('loads parent metadata from the downloaded Jellyfin user scope', () async {
@@ -619,7 +620,7 @@ void main() {
});
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
@@ -665,7 +666,7 @@ void main() {
'UserData': {'PlayCount': 1, 'Played': true},
});
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
@@ -677,7 +678,7 @@ void main() {
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1');
downloadManager.setClientResolver((serverId, {clientScopeId}) {
if (serverId == 'jf-machine') {
return _ScopedTestClient(serverId: 'jf-machine', scopedServerId: 'jf-machine/user-b');
return _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b');
}
return null;
});
@@ -709,7 +710,7 @@ void main() {
'UserData': {'PlayCount': 0},
});
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
@@ -721,14 +722,14 @@ void main() {
await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1');
await db.insertWatchAction(
profileId: 'test-profile',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'ep-1',
actionType: 'watched',
);
downloadManager.setClientResolver((serverId, {clientScopeId}) {
if (serverId == 'jf-machine') {
return _ScopedTestClient(serverId: 'jf-machine', scopedServerId: 'jf-machine/user-b');
return _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b');
}
return null;
});
@@ -766,7 +767,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv',
serverId: ServerId('srv'),
durationMs: 100000,
viewOffsetMs: 12000,
viewCount: 0,
@@ -792,7 +793,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv',
serverId: ServerId('srv'),
durationMs: 100000,
viewOffsetMs: 0,
viewCount: 1,
@@ -833,7 +834,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Ep 42',
serverId: 'srv',
serverId: ServerId('srv'),
),
},
artwork: {key: const DownloadedArtwork(thumbPath: '/art/42.jpg')},
@@ -908,7 +909,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season 7',
serverId: 'srv',
serverId: ServerId('srv'),
);
expect(p.getMetadata('srv:7'), isNull);
@@ -931,7 +932,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Original Title',
serverId: 'srv',
serverId: ServerId('srv'),
);
p.debugSeedState(metadata: {'srv:7': preexisting});
@@ -940,7 +941,7 @@ void main() {
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'New Title',
serverId: 'srv',
serverId: ServerId('srv'),
);
await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA<StateError>()));
+29 -24
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
@@ -12,7 +13,7 @@ import 'package:plezy/services/storage_service.dart';
import '../test_helpers/prefs.dart';
MediaLibrary _lib(String key, {String type = 'movie', String? serverId, String title = 'L'}) => MediaLibrary(
MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary(
id: key,
backend: MediaBackend.plex,
title: title,
@@ -20,7 +21,7 @@ MediaLibrary _lib(String key, {String type = 'movie', String? serverId, String t
serverId: serverId,
);
MediaLibrary _serverLib(String serverId, String id, String title) =>
MediaLibrary _serverLib(ServerId serverId, String id, String title) =>
MediaLibrary(id: id, backend: MediaBackend.plex, title: title, kind: MediaKind.movie, serverId: serverId);
/// Minimal [MediaServerClient] returning canned libraries; only the surface the
@@ -31,7 +32,7 @@ class _FakeClient implements MediaServerClient {
_FakeClient({required this.serverId, this.libraries = const [], this.gate});
@override
final String serverId;
final ServerId serverId;
@override
final String serverName = 'Server';
@@ -109,9 +110,9 @@ void main() {
p.addListener(() => notified++);
final libs = [
_lib('1', serverId: 'srv', title: 'A'),
_lib('2', serverId: 'srv', title: 'B'),
_lib('3', serverId: 'srv', title: 'C'),
_lib('1', serverId: ServerId('srv'), title: 'A'),
_lib('2', serverId: ServerId('srv'), title: 'B'),
_lib('3', serverId: ServerId('srv'), title: 'C'),
];
await p.updateLibraryOrder(libs);
@@ -128,14 +129,14 @@ void main() {
test('libraries getter returns an unmodifiable list', () async {
final p = LibrariesProvider();
await p.updateLibraryOrder([_lib('1', serverId: 'srv')]);
await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]);
expect(() => p.libraries.add(_lib('mutated')), throwsUnsupportedError);
p.dispose();
});
test('clear resets state to initial and notifies', () async {
final p = LibrariesProvider();
await p.updateLibraryOrder([_lib('1', serverId: 'srv'), _lib('2', serverId: 'srv')]);
await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv')), _lib('2', serverId: ServerId('srv'))]);
expect(p.libraries, hasLength(2));
var notified = 0;
@@ -157,14 +158,14 @@ void main() {
// Post-dispose clear / updateLibraryOrder must not throw — the provider
// uses `safeNotifyListeners` which swallows post-dispose firings.
p.clear();
await p.updateLibraryOrder([_lib('1', serverId: 'srv')]);
await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]);
});
});
group('LibrariesProvider.syncToOnlineServers', () {
test('loads when a server first comes online', () async {
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
manager.debugRegisterClientForTesting(clientA);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -180,7 +181,7 @@ void main() {
test('does not reload when the online set is unchanged', () async {
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
manager.debugRegisterClientForTesting(clientA);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -198,14 +199,14 @@ void main() {
// slow server reconnecting after timing out) was never picked up because
// the load was one-shot.
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
manager.debugRegisterClientForTesting(clientA);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
await p.syncToOnlineServers({'A'});
expect(p.libraries.map((l) => l.title), ['Movies A']);
final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]);
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
manager.debugRegisterClientForTesting(clientB);
await p.syncToOnlineServers({'A', 'B'});
@@ -219,7 +220,7 @@ void main() {
test('a background reload over existing data never surfaces a loading state', () async {
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
manager.debugRegisterClientForTesting(clientA);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -232,7 +233,7 @@ void main() {
final sawLoading = <bool>[];
p.addListener(() => sawLoading.add(p.isLoading));
final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]);
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
manager.debugRegisterClientForTesting(clientB);
await p.syncToOnlineServers({'A', 'B'});
@@ -250,8 +251,8 @@ void main() {
// failed server out of _loadedServerIds so it reloads instead of staying
// missing until a profile re-switch/restart.
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')])
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')])
..error = Exception('transient');
manager.debugRegisterClientForTesting(clientA);
manager.debugRegisterClientForTesting(clientB);
@@ -274,8 +275,8 @@ void main() {
test('does not reload when the online set shrinks', () async {
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
manager.debugRegisterClientForTesting(clientA);
manager.debugRegisterClientForTesting(clientB);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -295,7 +296,7 @@ void main() {
test('a zero-library server is marked loaded and does not retrigger', () async {
final manager = MultiServerManager();
final clientC = _FakeClient(serverId: 'C', libraries: const []);
final clientC = _FakeClient(serverId: ServerId('C'), libraries: const []);
manager.debugRegisterClientForTesting(clientC);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -317,7 +318,11 @@ void main() {
test('a server appearing mid-load is still picked up', () async {
final manager = MultiServerManager();
final gate = Completer<void>();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')], gate: gate.future);
final clientA = _FakeClient(
serverId: ServerId('A'),
libraries: [_serverLib(ServerId('A'), '1', 'Movies A')],
gate: gate.future,
);
manager.debugRegisterClientForTesting(clientA);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -325,7 +330,7 @@ void main() {
final inFlight = p.syncToOnlineServers({'A'});
// B comes online before the first load completes.
final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]);
final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]);
manager.debugRegisterClientForTesting(clientB);
unawaited(p.syncToOnlineServers({'A', 'B'})); // queued behind the in-flight pass
@@ -341,7 +346,7 @@ void main() {
test('clear() resets tracking so the next sync reloads', () async {
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
manager.debugRegisterClientForTesting(clientA);
final p = LibrariesProvider()..initialize(DataAggregationService(manager));
@@ -360,7 +365,7 @@ void main() {
test('is a no-op for an empty set or before initialize', () async {
final manager = MultiServerManager();
final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]);
final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]);
manager.debugRegisterClientForTesting(clientA);
// Empty set on an initialized provider.
+17 -16
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
@@ -39,8 +40,8 @@ void main() {
test('isServerOnline / getClientForServer return defaults for unknown ids', () {
final p = MultiServerProvider(manager, aggregation);
expect(p.isServerOnline('nope'), isFalse);
expect(p.getClientForServer('nope'), isNull);
expect(p.isServerOnline(ServerId('nope')), isFalse);
expect(p.getClientForServer(ServerId('nope')), isNull);
p.dispose();
});
@@ -73,7 +74,7 @@ void main() {
p.addListener(() => notified++);
// Push a status change through the manager's public API.
manager.updateServerStatus('srv-1', true);
manager.updateServerStatus(ServerId('srv-1'), true);
// Give the broadcast stream microtask time to deliver.
await Future<void>.delayed(Duration.zero);
@@ -87,7 +88,7 @@ void main() {
final calls = <Set<String>>[];
p.onOnlineServersChanged = calls.add;
manager.updateServerStatus('srv-1', true);
manager.updateServerStatus(ServerId('srv-1'), true);
await Future<void>.delayed(Duration.zero);
expect(calls, isNotEmpty);
expect(calls.last, {'srv-1'});
@@ -95,7 +96,7 @@ void main() {
// A server that is online in the manager but outside the active profile's
// visibility filter must not appear in the payload.
p.setVisibleServerIds({'srv-1'});
manager.updateServerStatus('srv-2', true);
manager.updateServerStatus(ServerId('srv-2'), true);
await Future<void>.delayed(Duration.zero);
expect(calls.last, {'srv-1'}, reason: 'srv-2 is online but filtered out');
@@ -146,15 +147,15 @@ void main() {
p.addListener(() => notified++);
// No prior filter — first add seeds it as a one-element set.
p.addToVisibleServerIds('srv-1');
p.addToVisibleServerIds(ServerId('srv-1'));
expect(notified, 1);
// Build up incrementally.
p.addToVisibleServerIds('srv-2');
p.addToVisibleServerIds(ServerId('srv-2'));
expect(notified, 2);
// Idempotent on already-present ids.
p.addToVisibleServerIds('srv-1');
p.addToVisibleServerIds(ServerId('srv-1'));
expect(notified, 2);
p.dispose();
@@ -167,16 +168,16 @@ void main() {
// status). The serverIds list requires actual server registration
// which goes through addPlexAccount/addJellyfinConnection — beyond
// what this unit test needs to cover.
manager.updateServerStatus('srv-1', true);
manager.updateServerStatus('srv-2', true);
manager.updateServerStatus('srv-3', false);
manager.updateServerStatus(ServerId('srv-1'), true);
manager.updateServerStatus(ServerId('srv-2'), true);
manager.updateServerStatus(ServerId('srv-3'), false);
// No filter — every online id passes through.
expect(p.onlineServerIds, containsAll({'srv-1', 'srv-2'}));
p.setVisibleServerIds({'srv-1'});
expect(p.onlineServerIds, ['srv-1']);
expect(p.isServerOnline('srv-2'), isFalse, reason: 'filtered out even when manager reports online');
expect(p.isServerOnline(ServerId('srv-2')), isFalse, reason: 'filtered out even when manager reports online');
// Empty filter blocks everything — covers the "no connections" path
// for a freshly-created profile that hasn't borrowed anything yet.
@@ -221,16 +222,16 @@ void main() {
p.setVisibleServerIds({'srv-1'});
p.setExpectedVisibleServerIds({'srv-1', 'srv-2'});
manager.updateServerStatus('srv-1', true);
manager.updateServerStatus(ServerId('srv-1'), true);
await Future<void>.delayed(Duration.zero);
expect(p.onlineServerIds, ['srv-1']);
manager.updateServerStatus('srv-2', true);
manager.updateServerStatus(ServerId('srv-2'), true);
await Future<void>.delayed(Duration.zero);
expect(p.onlineServerIds, containsAllInOrder(['srv-1', 'srv-2']));
expect(p.isServerOnline('srv-2'), isTrue);
expect(p.isServerOnline(ServerId('srv-2')), isTrue);
expect(onlineCalls.last, {'srv-1', 'srv-2'});
p.dispose();
@@ -244,7 +245,7 @@ void main() {
p.addListener(() => notifyCount++);
// Sanity: subscription works pre-dispose.
manager.updateServerStatus('a', true);
manager.updateServerStatus(ServerId('a'), true);
await Future<void>.delayed(Duration.zero);
expect(notifyCount, greaterThanOrEqualTo(1));
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
@@ -40,7 +41,7 @@ void main() {
test('reads online server IDs from the manager at construction', () {
final manager = MultiServerManager();
manager.updateServerStatus('srv-1', true);
manager.updateServerStatus(ServerId('srv-1'), true);
final p = OfflineModeProvider(manager);
expect(p.hasServerConnection, isTrue);
@@ -58,8 +59,8 @@ void main() {
// fresh-cold-start manager), so we stay optimistic until the
// provider's own listener catches an emission.
final manager = MultiServerManager();
manager.updateServerStatus('srv-1', false);
manager.updateServerStatus('srv-2', false);
manager.updateServerStatus(ServerId('srv-1'), false);
manager.updateServerStatus(ServerId('srv-2'), false);
final p = OfflineModeProvider(manager);
expect(p.hasServerConnection, isFalse);
@@ -93,7 +94,7 @@ void main() {
test('OfflineModeSource interface contract: isOffline is exposed', () {
final manager = MultiServerManager();
manager.updateServerStatus('srv', true);
manager.updateServerStatus(ServerId('srv'), true);
final p = OfflineModeProvider(manager);
// The provider implements OfflineModeSource — its isOffline getter is the
@@ -110,7 +111,7 @@ void main() {
// hasServerConnection reflects the manager's state and isOffline
// is correctly false (network up + server up).
final manager = MultiServerManager();
manager.updateServerStatus('srv', true);
manager.updateServerStatus(ServerId('srv'), true);
final p = OfflineModeProvider(manager);
expect(p.hasServerConnection, isTrue);
@@ -131,7 +132,7 @@ void main() {
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
manager.debugMarkAuthErrorForTesting('jf-machine');
manager.debugMarkAuthErrorForTesting(ServerId('jf-machine'));
await Future<void>.delayed(Duration.zero);
expect(multi.authErrorServerIds, contains('jf-machine'));
@@ -147,7 +148,7 @@ void main() {
final multi = MultiServerProvider(manager, DataAggregationService(manager));
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
manager.updateServerStatus('plex-server', false);
manager.updateServerStatus(ServerId('plex-server'), false);
await Future<void>.delayed(Duration.zero);
expect(p.isOffline, isFalse);
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/providers/download_provider.dart';
@@ -67,7 +68,12 @@ void main() {
test('getViewOffset returns null for local progress that crossed watched threshold', () async {
final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
await syncService.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000);
await syncService.queueProgressUpdate(
serverId: ServerId('srv'),
itemId: '42',
viewOffset: 95000,
duration: 100000,
);
expect(await p.isWatched('srv:42'), isTrue);
expect(await p.getViewOffset('srv:42'), isNull);
@@ -95,7 +101,7 @@ void main() {
// queueMarkWatched on the sync service notifies its listeners; the
// provider's internal listener forwards via safeNotifyListeners.
await syncService.queueMarkWatched(serverId: 'srv', itemId: '42');
await syncService.queueMarkWatched(serverId: ServerId('srv'), itemId: '42');
expect(notified, greaterThanOrEqualTo(1));
p.dispose();
@@ -107,7 +113,7 @@ void main() {
var notified = 0;
p.addListener(() => notified++);
await p.markAsWatched(serverId: 'srv', itemId: '50');
await p.markAsWatched(serverId: ServerId('srv'), itemId: '50');
// The local watch status now reads as true via the sync service.
expect(await p.isWatched('srv:50'), isTrue);
@@ -121,7 +127,7 @@ void main() {
test('markAsUnwatched queues an offline action and notifies', () async {
final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
await p.markAsUnwatched(serverId: 'srv', itemId: '60');
await p.markAsUnwatched(serverId: ServerId('srv'), itemId: '60');
expect(await p.isWatched('srv:60'), isFalse);
p.dispose();
@@ -134,7 +140,7 @@ void main() {
p.addListener(() => notified++);
// Sanity: listener is registered
await syncService.queueMarkWatched(serverId: 'srv', itemId: '70');
await syncService.queueMarkWatched(serverId: ServerId('srv'), itemId: '70');
final preDisposeNotifies = notified;
expect(preDisposeNotifies, greaterThanOrEqualTo(1));
@@ -143,7 +149,7 @@ void main() {
// After dispose, sync service notifications should not call our
// listener (provider unsubscribed). Mutating the sync service post-
// dispose must not throw on the provider side.
await syncService.queueMarkUnwatched(serverId: 'srv', itemId: '70');
await syncService.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '70');
expect(notified, preDisposeNotifies);
});
});
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/providers/watch_state_overlay_provider.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
@@ -17,7 +18,7 @@ WatchStateEvent _event({
}) {
return WatchStateEvent(
itemId: itemId,
serverId: serverId,
serverId: ServerId(serverId),
cacheServerId: cacheServerId,
changeType: changeType,
parentChain: const [],
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -225,7 +226,7 @@ class _FakeMediaServerClient implements MediaServerClient {
_FakeMediaServerClient({required this.hubs});
@override
String get serverId => 'server_1';
ServerId get serverId => ServerId('server_1');
@override
String? get serverName => 'Server';
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -73,7 +74,7 @@ JellyfinClient _jellyfinClient(JellyfinConnection connection) {
);
}
MediaItem _show(String serverId, String ratingKey, String title) {
MediaItem _show(ServerId serverId, String ratingKey, String title) {
return MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.show, title: title, serverId: serverId);
}
@@ -116,7 +117,7 @@ void main() {
await db.close();
});
Future<void> insertRule(String serverId, String ratingKey) {
Future<void> insertRule(ServerId serverId, String ratingKey) {
return downloadProvider.createSyncRule(
serverId: serverId,
ratingKey: ratingKey,
@@ -128,10 +129,10 @@ void main() {
Future<void> pumpScreen(WidgetTester tester, {bool keyboardMode = false}) async {
downloadProvider.debugSeedState(
metadata: {
'plex-srv:show-1': _show('plex-srv', 'show-1', 'Plex Show'),
'jf-machine:show-2': _show('jf-machine', 'show-2', 'Jellyfin Show'),
'auth-jf:show-3': _show('auth-jf', 'show-3', 'Auth Show'),
'unknown-srv:show-4': _show('unknown-srv', 'show-4', 'Unknown Show'),
'plex-srv:show-1': _show(ServerId('plex-srv'), 'show-1', 'Plex Show'),
'jf-machine:show-2': _show(ServerId('jf-machine'), 'show-2', 'Jellyfin Show'),
'auth-jf:show-3': _show(ServerId('auth-jf'), 'show-3', 'Auth Show'),
'unknown-srv:show-4': _show(ServerId('unknown-srv'), 'show-4', 'Unknown Show'),
},
);
@@ -196,13 +197,13 @@ void main() {
final authClient = _jellyfinClient(authJellyfin);
addTearDown(authClient.close);
serverManager.debugRegisterJellyfinClientForTesting(authClient, online: false);
serverManager.debugMarkAuthErrorForTesting('auth-jf');
serverManager.debugMarkAuthErrorForTesting(ServerId('auth-jf'));
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertRule('plex-srv', 'show-1');
await insertRule('jf-machine', 'show-2');
await insertRule('auth-jf', 'show-3');
await insertRule('unknown-srv', 'show-4');
await insertRule(ServerId('plex-srv'), 'show-1');
await insertRule(ServerId('jf-machine'), 'show-2');
await insertRule(ServerId('auth-jf'), 'show-3');
await insertRule(ServerId('unknown-srv'), 'show-4');
await pumpScreen(tester);
@@ -218,7 +219,7 @@ void main() {
testWidgets('removes orphaned sync rules from the sync rules screen', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertRule('orphan-srv', '76672');
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester);
@@ -241,7 +242,7 @@ void main() {
testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertRule('orphan-srv', '76672');
await insertRule(ServerId('orphan-srv'), '76672');
FocusManager.instance.primaryFocus?.unfocus();
await pumpScreen(tester);
@@ -252,7 +253,7 @@ void main() {
testWidgets('keyboard navigation reaches and toggles the sync rule switch', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertRule('orphan-srv', '76672');
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester, keyboardMode: true);
@@ -270,7 +271,7 @@ void main() {
testWidgets('setting sync rule count to zero removes the rule', (tester) async {
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
await insertRule('orphan-srv', '76672');
await insertRule(ServerId('orphan-srv'), '76672');
await pumpScreen(tester, keyboardMode: true);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
+2 -1
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -429,7 +430,7 @@ class _FakeMediaServerClient implements MediaServerClient {
});
@override
String get serverId => 'server_1';
ServerId get serverId => ServerId('server_1');
@override
String? get serverName => 'Server';
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
@@ -208,7 +209,7 @@ class _PagedPlaylistClient implements MediaServerClient {
_PagedPlaylistClient(this.items);
@override
String get serverId => 'server_1';
ServerId get serverId => ServerId('server_1');
@override
String? get serverName => 'Server';
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -73,7 +74,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'plex-1',
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient((req) async {
plexRequests.add(req.url);
@@ -131,7 +132,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'plex-1',
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient((req) async {
captured.add(req.url);
@@ -175,7 +176,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'plex-1',
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient((req) async {
if (req.url.path == '/hubs') {
@@ -251,7 +252,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'plex-1',
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient((req) async {
if (req.url.path == '/hubs') {
@@ -426,7 +427,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'plex-1',
serverId: ServerId('plex-1'),
serverName: 'Plex',
promotedHubKey: '/hubs/promoted',
httpClient: MockClient((req) async {
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'dart:convert';
import 'dart:io';
@@ -119,7 +120,7 @@ void main() {
const tokenized = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret';
const sanitized = 'https://jf/Items/1/Images/Logo?tag=abc';
expect(await service.localPath('srv', tokenized), await service.localPath('srv', sanitized));
expect(await service.localPath(ServerId('srv'), tokenized), await service.localPath(ServerId('srv'), sanitized));
});
test('downloadFile rejects non-success responses without leaving final files', () async {
@@ -146,16 +147,16 @@ void main() {
);
const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret';
final filePath = await service.localPath('srv', rawPath);
final filePath = await service.localPath(ServerId('srv'), rawPath);
await File(filePath).writeAsString('<html>not an image</html>');
await service.downloadSingleArtwork(
'srv',
ServerId('srv'),
DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'),
);
expect(await File(filePath).readAsBytes(), body);
expect(await service.existsUsable('srv', rawPath), isTrue);
expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue);
});
test('downloadSingleArtwork serializes duplicate writes to the same local file', () async {
@@ -170,15 +171,15 @@ void main() {
const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret';
final spec = DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png');
final first = service.downloadSingleArtwork('srv', spec);
final first = service.downloadSingleArtwork(ServerId('srv'), spec);
await Future<void>.delayed(Duration.zero);
final second = service.downloadSingleArtwork('srv', spec);
final second = service.downloadSingleArtwork(ServerId('srv'), spec);
await Future<void>.delayed(Duration.zero);
httpClient.release.complete();
await Future.wait([first, second]);
expect(httpClient.sends, 1);
expect(await service.existsUsable('srv', rawPath), isTrue);
expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue);
});
}
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
@@ -83,7 +84,7 @@ void main() {
.into(db.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: const Value('jf-machine/user-a'),
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -103,10 +104,13 @@ void main() {
final manager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance)
..setClientResolver((serverId, {clientScopeId}) {
return _ScopedJellyfinClient(serverId: serverId, scopedServerId: clientScopeId ?? 'jf-machine/user-b');
return _ScopedJellyfinClient(
serverId: ServerId(serverId),
scopedServerId: clientScopeId ?? 'jf-machine/user-b',
);
});
final item = await manager.lookupMetadata('jf-machine', 'item-1', preferActiveScope: true);
final item = await manager.lookupMetadata(ServerId('jf-machine'), 'item-1', preferActiveScope: true);
expect(item?.title, 'Cached for User A');
expect(item?.serverId, 'jf-machine');
@@ -118,7 +122,7 @@ void main() {
JellyfinApiCache.initialize(db);
addTearDown(db.close);
await PlexApiCache.instance.put('srv-1', '/library/metadata/show-1', {
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/show-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'type': 'show', 'title': 'The Show', 'year': 2008},
@@ -132,7 +136,7 @@ void main() {
id: 'ep-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
serverId: 'srv-1',
serverId: ServerId('srv-1'),
title: 'Episode from 2010',
year: 2010,
grandparentId: 'show-1',
@@ -151,25 +155,25 @@ void main() {
JellyfinApiCache.initialize(db);
addTearDown(db.close);
await JellyfinApiCache.instance.put('jf-machine/user-a', '/Users/user-a/Items/item-1', {
await JellyfinApiCache.instance.put(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1', {
'Id': 'item-1',
'Type': 'Episode',
'Name': 'Episode',
});
await JellyfinApiCache.instance.put('jf-machine/user-a', '/MediaSegments/item-1', {
await JellyfinApiCache.instance.put(ServerId('jf-machine/user-a'), '/MediaSegments/item-1', {
'Items': [
{'Type': 'Intro', 'StartTicks': 10000000, 'EndTicks': 20000000},
],
});
await JellyfinApiCache.instance.pinForOffline('jf-machine/user-a', 'item-1');
await JellyfinApiCache.instance.pinForOffline(ServerId('jf-machine/user-a'), 'item-1');
expect(await JellyfinApiCache.instance.isPinned('jf-machine/user-a', '/MediaSegments/item-1'), isTrue);
expect(await JellyfinApiCache.instance.isPinned(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isTrue);
await JellyfinApiCache.instance.deleteForItem('jf-machine/user-a', 'item-1');
await JellyfinApiCache.instance.deleteForItem(ServerId('jf-machine/user-a'), 'item-1');
expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/Users/user-a/Items/item-1'), isNull);
expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/MediaSegments/item-1'), isNull);
expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1'), isNull);
expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isNull);
});
test('artwork repair fetches full parent metadata and backfills thumb path', () async {
@@ -196,7 +200,7 @@ void main() {
.into(db.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'ep-1',
globalKey: 'srv:ep-1',
type: 'episode',
@@ -205,7 +209,7 @@ void main() {
status: DownloadStatus.completed.index,
),
);
await PlexApiCache.instance.put('srv', '/library/metadata/ep-1', {
await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/ep-1', {
'MediaContainer': {
'Metadata': [
{
@@ -222,7 +226,7 @@ void main() {
],
},
});
await PlexApiCache.instance.put('srv', '/library/metadata/show-1', {
await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/show-1', {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'thumb': '/show-thumb'},
@@ -231,13 +235,13 @@ void main() {
});
final client = _ArtworkRepairClient(
serverId: 'srv',
serverId: ServerId('srv'),
items: {
'show-1': MediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
serverId: 'srv',
serverId: ServerId('srv'),
title: 'Show',
thumbPath: '/show-thumb',
clearLogoPath: '/show-logo',
@@ -256,7 +260,7 @@ void main() {
expect(client.fetchCounts['show-1'], isNotNull);
expect(client.fetchCounts['show-1']!, greaterThan(0));
final logoPath = DownloadArtworkService.localPathSync(storage, 'srv', '/show-logo');
final logoPath = DownloadArtworkService.localPathSync(storage, ServerId('srv'), '/show-logo');
expect(logoPath, isNotNull);
expect(File(logoPath!).existsSync(), isTrue);
final row = await db.getDownloadedMedia('srv:ep-1');
@@ -270,7 +274,7 @@ void main() {
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
@@ -305,7 +309,7 @@ void main() {
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
@@ -333,7 +337,7 @@ void main() {
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
@@ -365,7 +369,7 @@ void main() {
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
@@ -398,7 +402,7 @@ void main() {
addTearDown(db.close);
const globalKey = 'srv:item-1';
await db.insertDownload(
serverId: 'srv',
serverId: ServerId('srv'),
ratingKey: 'item-1',
globalKey: globalKey,
type: 'movie',
@@ -443,7 +447,7 @@ MediaItem _movie({String? thumbPath}) {
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
thumbPath: thumbPath,
);
}
@@ -452,7 +456,7 @@ class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClien
_ScopedJellyfinClient({required this.serverId, required this.scopedServerId});
@override
final String serverId;
final ServerId serverId;
@override
final String scopedServerId;
@@ -504,7 +508,7 @@ class _ArtworkRepairClient implements MediaServerClient {
_ArtworkRepairClient({required this.serverId, required this.items});
@override
final String serverId;
final ServerId serverId;
final Map<String, MediaItem> items;
final fetchCounts = <String, int>{};
@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
@@ -234,14 +235,14 @@ void main() {
final dss = DownloadStorageService.instance;
// Before initialize() the sync getter is null.
expect(dss.artworkDirectoryPath, isNull);
expect(dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'), isNull);
expect(dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/1/thumb'), isNull);
final settings = await SettingsService.getInstance();
await dss.initialize(settings);
final p1 = dss.getArtworkPathSync('srv', '/library/metadata/1/thumb');
final p2 = dss.getArtworkPathSync('srv', '/library/metadata/1/thumb');
final p3 = dss.getArtworkPathSync('srv', '/library/metadata/2/thumb');
final p1 = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/1/thumb');
final p2 = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/1/thumb');
final p3 = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/2/thumb');
expect(p1, isNotNull);
// Same input → same path (MD5 of `serverId:thumbPath`).
expect(p1, p2);
@@ -254,8 +255,8 @@ void main() {
final dss = DownloadStorageService.instance;
await dss.initialize(settings);
final asyncPath = await dss.getArtworkPathFromThumb('srv', '/library/metadata/9/thumb');
final syncPath = dss.getArtworkPathSync('srv', '/library/metadata/9/thumb');
final asyncPath = await dss.getArtworkPathFromThumb(ServerId('srv'), '/library/metadata/9/thumb');
final syncPath = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/9/thumb');
expect(asyncPath, syncPath);
});
@@ -264,11 +265,11 @@ void main() {
final dss = DownloadStorageService.instance;
await dss.initialize(settings);
expect(await dss.artworkExists('srv', '/thumb/1'), isFalse);
expect(await dss.artworkExists(ServerId('srv'), '/thumb/1'), isFalse);
final filePath = await dss.getArtworkPathFromThumb('srv', '/thumb/1');
final filePath = await dss.getArtworkPathFromThumb(ServerId('srv'), '/thumb/1');
await File(filePath).writeAsString('fake-artwork');
expect(await dss.artworkExists('srv', '/thumb/1'), isTrue);
expect(await dss.artworkExists(ServerId('srv'), '/thumb/1'), isTrue);
});
});
@@ -521,7 +522,7 @@ void main() {
final dss = DownloadStorageService.instance;
await dss.initialize(settings);
final dir = await dss.getMediaDirectory('srv-1', '42');
final dir = await dss.getMediaDirectory(ServerId('srv-1'), '42');
expect(dir.existsSync(), isTrue);
final downloads = await dss.getDownloadsDirectory();
expect(dir.path, p.join(downloads.path, 'srv-1', '42'));
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -32,7 +33,7 @@ import 'package:provider/provider.dart';
MediaItem _meta(String id, {String? title}) =>
MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
MediaItem _jfEpisode(String id, {required String seriesId, String serverId = 'srv-jf'}) => MediaItem(
MediaItem _jfEpisode(String id, {required String seriesId, ServerId serverId = const ServerId('srv-jf')}) => MediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_backend.dart';
@@ -18,7 +19,7 @@ class _RecordingClient implements MediaServerClient {
final stopped = <({int positionMs, int? durationMs})>[];
@override
String get serverId => 'srv';
ServerId get serverId => ServerId('srv');
@override
MediaBackend get backend => MediaBackend.plex;
+30 -29
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
@@ -63,7 +64,7 @@ void main() {
// `serverId:/Users/{userId}/Items/{itemId}` — mirror the shape exactly so
// we exercise the same lookup pattern.
Future<void> putItemRow({
required String serverId,
required ServerId serverId,
required String userId,
required String itemId,
Map<String, dynamic>? data,
@@ -91,13 +92,13 @@ void main() {
const userId = 'jf-user';
await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin');
await putItemRow(
serverId: machineId,
serverId: ServerId(machineId),
userId: userId,
itemId: 'item-1',
data: jellyfinItem(id: 'item-1', name: 'A Movie'),
);
final meta = await cache.getMetadata(machineId, 'item-1');
final meta = await cache.getMetadata(ServerId(machineId), 'item-1');
expect(meta, isNotNull, reason: 'cache lookup must succeed despite id-format mismatch');
expect(meta!.title, 'A Movie');
expect(meta.serverId, machineId);
@@ -106,8 +107,8 @@ void main() {
test('returns null when the connection row is missing', () async {
// Cache row exists but no Connections row → lookup can't resolve serverName.
await putItemRow(serverId: 'orphan', userId: 'u', itemId: 'item-1');
expect(await cache.getMetadata('orphan', 'item-1'), isNull);
await putItemRow(serverId: ServerId('orphan'), userId: 'u', itemId: 'item-1');
expect(await cache.getMetadata(ServerId('orphan'), 'item-1'), isNull);
});
test('absolutizes image paths against the connection baseUrl + accessToken', () async {
@@ -118,7 +119,7 @@ void main() {
const userId = 'jf-user';
await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin');
await putItemRow(
serverId: machineId,
serverId: ServerId(machineId),
userId: userId,
itemId: 'item-1',
data: {
@@ -129,7 +130,7 @@ void main() {
},
);
final meta = await cache.getMetadata(machineId, 'item-1');
final meta = await cache.getMetadata(ServerId(machineId), 'item-1');
expect(meta, isNotNull);
expect(meta!.thumbPath, 'http://example.lan/Items/item-1/Images/Primary?tag=tag-abc&api_key=token');
expect(meta.clearLogoPath, 'http://example.lan/Items/item-1/Images/Logo?tag=tag-logo&api_key=token');
@@ -145,7 +146,7 @@ void main() {
accessToken: await CredentialVault.protect('secret-token'),
);
await putItemRow(
serverId: machineId,
serverId: ServerId(machineId),
userId: userId,
itemId: 'item-1',
data: {
@@ -156,7 +157,7 @@ void main() {
},
);
final meta = await cache.getMetadata(machineId, 'item-1');
final meta = await cache.getMetadata(ServerId(machineId), 'item-1');
expect(meta, isNotNull);
expect(meta!.thumbPath, contains('api_key=secret-token'));
expect(meta.thumbPath, isNot(contains('enc:v1:')));
@@ -167,7 +168,7 @@ void main() {
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
await putItemRow(
serverId: '$machineId/user-a',
serverId: ServerId('$machineId/user-a'),
userId: 'user-a',
itemId: 'item-1',
data: {
@@ -176,7 +177,7 @@ void main() {
},
);
await putItemRow(
serverId: '$machineId/user-b',
serverId: ServerId('$machineId/user-b'),
userId: 'user-b',
itemId: 'item-1',
data: {
@@ -185,8 +186,8 @@ void main() {
},
);
final a = await cache.getMetadata('$machineId/user-a', 'item-1');
final b = await cache.getMetadata('$machineId/user-b', 'item-1');
final a = await cache.getMetadata(ServerId('$machineId/user-a'), 'item-1');
final b = await cache.getMetadata(ServerId('$machineId/user-b'), 'item-1');
expect(a, isNotNull);
expect(b, isNotNull);
@@ -207,10 +208,10 @@ void main() {
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-1', pinned: true);
await putItemRow(serverId: machineId, userId: 'user-b', itemId: 'item-2', pinned: true);
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1', pinned: true);
await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-2', pinned: true);
// Unpinned row is filtered out.
await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-3');
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-3');
final pinned = await cache.getAllPinnedMetadata();
expect(pinned.keys.toSet(), {'$machineId:item-1', '$machineId:item-2'});
@@ -218,7 +219,7 @@ void main() {
});
test('skips pinned rows whose serverId has no matching connection', () async {
await putItemRow(serverId: 'orphan-machine', userId: 'u', itemId: 'lost', pinned: true);
await putItemRow(serverId: ServerId('orphan-machine'), userId: 'u', itemId: 'lost', pinned: true);
expect(await cache.getAllPinnedMetadata(), isEmpty);
});
@@ -227,7 +228,7 @@ void main() {
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
await putItemRow(
serverId: '$machineId/user-a',
serverId: ServerId('$machineId/user-a'),
userId: 'user-a',
itemId: 'item-1',
data: {
@@ -237,7 +238,7 @@ void main() {
pinned: true,
);
await putItemRow(
serverId: '$machineId/user-b',
serverId: ServerId('$machineId/user-b'),
userId: 'user-b',
itemId: 'item-1',
data: {
@@ -259,11 +260,11 @@ void main() {
group('pinForOffline', () {
test('pins by user-segment wildcard so a single call covers any user', () async {
const machineId = 'jf-machine';
await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-1');
await putItemRow(serverId: machineId, userId: 'user-b', itemId: 'item-1');
await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-2');
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1');
await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-1');
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-2');
await cache.pinForOffline(machineId, 'item-1');
await cache.pinForOffline(ServerId(machineId), 'item-1');
// Both per-user rows for item-1 get pinned, item-2 stays unpinned.
final rows = await db.select(db.apiCache).get();
@@ -273,10 +274,10 @@ void main() {
test('pins only the requested compound Jellyfin user scope', () async {
const machineId = 'jf-machine';
await putItemRow(serverId: '$machineId/user-a', userId: 'user-a', itemId: 'item-1');
await putItemRow(serverId: '$machineId/user-b', userId: 'user-b', itemId: 'item-1');
await putItemRow(serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1');
await putItemRow(serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1');
await cache.pinForOffline('$machineId/user-a', 'item-1');
await cache.pinForOffline(ServerId('$machineId/user-a'), 'item-1');
final rows = await db.select(db.apiCache).get();
final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet();
@@ -288,7 +289,7 @@ void main() {
test('mutates only the requested compound Jellyfin user scope', () async {
const machineId = 'jf-machine';
await putItemRow(
serverId: '$machineId/user-a',
serverId: ServerId('$machineId/user-a'),
userId: 'user-a',
itemId: 'item-1',
data: {
@@ -297,7 +298,7 @@ void main() {
},
);
await putItemRow(
serverId: '$machineId/user-b',
serverId: ServerId('$machineId/user-b'),
userId: 'user-b',
itemId: 'item-1',
data: {
@@ -306,7 +307,7 @@ void main() {
},
);
await cache.applyWatchState(serverId: '$machineId/user-a', itemId: 'item-1', isWatched: true);
await cache.applyWatchState(serverId: ServerId('$machineId/user-a'), itemId: 'item-1', isWatched: true);
final rows = await db.select(db.apiCache).get();
final byKey = {for (final row in rows) row.cacheKey: jsonDecode(row.data) as Map<String, dynamic>};
+31 -22
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_stream.dart';
@@ -43,7 +44,12 @@ void main() {
'BackdropImageTags': ['backtag'],
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, serverName: 'Home', absolutizer: null)!;
final item = JellyfinMappers.mediaItem(
json,
serverId: ServerId(_serverId),
serverName: 'Home',
absolutizer: null,
)!;
expect(item.id, 'abc123');
expect(item.backend, MediaBackend.jellyfin);
@@ -88,7 +94,7 @@ void main() {
'UserData': {'PlayCount': 1, 'Played': false},
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.viewCount, 0);
expect(item.isWatched, isFalse);
@@ -97,12 +103,12 @@ void main() {
test('maps generic Jellyfin video types to playable clips', () {
final video = JellyfinMappers.mediaItem(
{'Id': 'home-video', 'Name': 'Home Video', 'Type': 'Video'},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
final musicVideo = JellyfinMappers.mediaItem(
{'Id': 'music-video', 'Name': 'Music Video', 'Type': 'MusicVideo'},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
@@ -128,7 +134,7 @@ void main() {
'UserData': {'UnplayedItemCount': 0},
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.kind, MediaKind.episode);
expect(item.index, 1);
@@ -154,7 +160,7 @@ void main() {
'SeasonName': 'Season 1',
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.parentThumbPath, '/Items/season-1/Images/Primary');
expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary');
@@ -175,7 +181,7 @@ void main() {
'UserData': {'UnplayedItemCount': 4},
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.leafCount, 12);
expect(item.viewedLeafCount, 8);
@@ -198,7 +204,7 @@ void main() {
{'Type': 'Actor', 'Name': 'Actor', 'Id': 'person/id #1?x', 'PrimaryImageTag': 'person/tag ?x'},
],
},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
@@ -222,7 +228,7 @@ void main() {
'UserData': {'UnplayedItemCount': 7},
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.leafCount, 50);
expect(item.viewedLeafCount, 43);
@@ -273,7 +279,7 @@ void main() {
],
};
final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!;
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.mediaVersions, isNotNull);
final v = item.mediaVersions!.single;
expect(v.id, 'src-1');
@@ -323,7 +329,7 @@ void main() {
'Id': 'view-${entry.key}',
'Name': 'Library',
'CollectionType': entry.key,
}, serverId: _serverId)!;
}, serverId: ServerId(_serverId))!;
expect(lib.kind, entry.value, reason: 'CollectionType ${entry.key}');
expect(lib.backend, MediaBackend.jellyfin);
}
@@ -334,7 +340,7 @@ void main() {
'Id': 'view-x',
'Name': 'Mixed',
'CollectionType': 'mixed',
}, serverId: _serverId)!;
}, serverId: ServerId(_serverId))!;
expect(lib.kind, MediaKind.unknown);
});
});
@@ -347,7 +353,7 @@ void main() {
test('minimal payload (just Id + Type) yields a MediaItem with sane defaults', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'bare-1', 'Type': 'Movie'},
serverId: _serverId,
serverId: ServerId(_serverId),
serverName: 'Home',
absolutizer: null,
)!;
@@ -364,7 +370,7 @@ void main() {
test('missing UserData leaves watch state nullable without throwing', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X'},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
// Either 0 or null is acceptable as long as we don't crash.
@@ -376,7 +382,7 @@ void main() {
test('null People array does not crash', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'People': null},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.directors, anyOf(isNull, isEmpty));
@@ -387,7 +393,7 @@ void main() {
test('null Genres / Studios / ProductionLocations degrade gracefully', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'Genres': null, 'Studios': null, 'ProductionLocations': null},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.genres, anyOf(isNull, isEmpty));
@@ -398,7 +404,7 @@ void main() {
test('malformed RunTimeTicks does not throw — duration left null', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'RunTimeTicks': 'not-a-number'},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.durationMs, isNull);
@@ -407,7 +413,7 @@ void main() {
test('null MediaSources does not crash', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'MediaSources': null},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.mediaVersions, anyOf(isNull, isEmpty));
@@ -417,7 +423,7 @@ void main() {
group('JellyfinMappers.mediaItem missing-Id rejection', () {
test('returns null when Id is absent', () {
expect(
JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: _serverId, absolutizer: null),
JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: ServerId(_serverId), absolutizer: null),
isNull,
);
});
@@ -426,7 +432,7 @@ void main() {
expect(
JellyfinMappers.mediaItem(
{'Id': '', 'Type': 'Movie', 'Name': 'emptyId'},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
),
isNull,
@@ -443,7 +449,7 @@ void main() {
{'Id': 'src-ok', 'Container': 'mp4', 'Bitrate': 4000000, 'MediaStreams': []},
],
},
serverId: _serverId,
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.mediaVersions!.length, 1);
@@ -453,7 +459,10 @@ void main() {
group('JellyfinMappers.library missing-Id rejection', () {
test('returns null when Id is absent', () {
expect(JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: _serverId), isNull);
expect(
JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: ServerId(_serverId)),
isNull,
);
});
});
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
@@ -81,7 +82,7 @@ class _RecordingJellyfinClient implements JellyfinClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem(
MediaItem _ep(String id, {ServerId? serverId = const ServerId('srv-jf')}) => MediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
@@ -89,13 +90,13 @@ MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem(
serverId: serverId,
);
MediaItem _movie(String id, {String? serverId = 'srv-jf'}) =>
MediaItem _movie(String id, {ServerId? serverId = const ServerId('srv-jf')}) =>
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, title: 'Movie $id', serverId: serverId);
MediaItem _clip(String id, {String? serverId = 'srv-jf'}) =>
MediaItem _clip(String id, {ServerId? serverId = const ServerId('srv-jf')}) =>
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.clip, title: 'Video $id', serverId: serverId);
MediaItem _track(String id, {String? serverId = 'srv-jf'}) =>
MediaItem _track(String id, {ServerId? serverId = const ServerId('srv-jf')}) =>
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.track, title: 'Track $id', serverId: serverId);
void main() {
+30 -29
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -77,9 +78,9 @@ void main() {
final m = MultiServerManager();
addTearDown(m.dispose);
expect(m.getClient('nope'), isNull);
expect(m.getPlexServer('nope'), isNull);
expect(m.isServerOnline('nope'), isFalse);
expect(m.getClient(ServerId('nope')), isNull);
expect(m.getPlexServer(ServerId('nope')), isNull);
expect(m.isServerOnline(ServerId('nope')), isFalse);
});
test('plexServers map is unmodifiable', () {
@@ -106,9 +107,9 @@ void main() {
addTearDown(sub.cancel);
// Pre-seed status (mirrors what addServer would do post-connect).
m.updateServerStatus('srv-1', true);
m.updateServerStatus('srv-2', false);
m.updateServerStatus('srv-1', false); // change
m.updateServerStatus(ServerId('srv-1'), true);
m.updateServerStatus(ServerId('srv-2'), false);
m.updateServerStatus(ServerId('srv-1'), false); // change
// Let the broadcast stream events drain.
await Future<void>.delayed(Duration.zero);
@@ -127,9 +128,9 @@ void main() {
final sub = m.statusStream.listen(emitted.add);
addTearDown(sub.cancel);
m.updateServerStatus('srv-1', true);
m.updateServerStatus('srv-1', true); // same value: no-op
m.updateServerStatus('srv-1', true);
m.updateServerStatus(ServerId('srv-1'), true);
m.updateServerStatus(ServerId('srv-1'), true); // same value: no-op
m.updateServerStatus(ServerId('srv-1'), true);
await Future<void>.delayed(Duration.zero);
expect(emitted, hasLength(1));
@@ -140,14 +141,14 @@ void main() {
final m = MultiServerManager();
addTearDown(m.dispose);
m.updateServerStatus('a', true);
m.updateServerStatus('b', false);
m.updateServerStatus('c', true);
m.updateServerStatus(ServerId('a'), true);
m.updateServerStatus(ServerId('b'), false);
m.updateServerStatus(ServerId('c'), true);
expect(m.onlineServerIds.toSet(), {'a', 'c'});
expect(m.offlineServerIds.toSet(), {'b'});
expect(m.isServerOnline('a'), isTrue);
expect(m.isServerOnline('b'), isFalse);
expect(m.isServerOnline(ServerId('a')), isTrue);
expect(m.isServerOnline(ServerId('b')), isFalse);
});
});
@@ -168,12 +169,12 @@ void main() {
product: 'Plezy',
version: '1.0.0',
),
serverId: 'server-1',
serverId: ServerId('server-1'),
serverName: 'Plex',
httpClient: MockClient((_) async => http.Response('{}', 200)),
);
m.debugRegisterClientForTesting(client, online: true);
m.debugMarkAuthErrorForTesting('server-1');
m.debugMarkAuthErrorForTesting(ServerId('server-1'));
final bound = await m.refreshTokensForProfile(
PlexAccountConnection(
@@ -254,8 +255,8 @@ void main() {
await m.checkServerHealth();
expect(m.isServerOnline('jf-machine'), isTrue);
expect(m.isOwnerOrAdmin('jf-machine'), isTrue);
expect(m.isServerOnline(ServerId('jf-machine')), isTrue);
expect(m.isOwnerOrAdmin(ServerId('jf-machine')), isTrue);
});
test('ignores stale admin-status persistence from a replaced Jellyfin client', () async {
@@ -315,8 +316,8 @@ void main() {
allowResponse.complete();
await healthFuture;
expect(m.getClient('jf-machine'), same(userB));
expect(m.isServerOnline('jf-machine'), isTrue);
expect(m.getClient(ServerId('jf-machine')), same(userB));
expect(m.isServerOnline(ServerId('jf-machine')), isTrue);
expect(m.authErrorServerIds, isNot(contains('jf-machine')));
});
});
@@ -330,14 +331,14 @@ void main() {
final m = MultiServerManager();
addTearDown(m.dispose);
m.updateServerStatus('srv-1', true);
m.updateServerStatus('srv-2', true);
m.updateServerStatus(ServerId('srv-1'), true);
m.updateServerStatus(ServerId('srv-2'), true);
final emitted = <Map<String, bool>>[];
final sub = m.statusStream.listen(emitted.add);
addTearDown(sub.cancel);
m.removeServer('srv-1');
m.removeServer(ServerId('srv-1'));
await Future<void>.delayed(Duration.zero);
expect(m.serverIds, isNot(contains('srv-1')));
@@ -353,7 +354,7 @@ void main() {
final sub = m.statusStream.listen(emitted.add);
addTearDown(sub.cancel);
m.removeServer('never-added');
m.removeServer(ServerId('never-added'));
await Future<void>.delayed(Duration.zero);
// Doesn't throw; state stays empty; one snapshot fires.
@@ -372,9 +373,9 @@ void main() {
expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNotNull);
expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNotNull);
m.removeServer('jf-machine');
m.removeServer(ServerId('jf-machine'));
expect(m.getClient('jf-machine'), isNull);
expect(m.getClient(ServerId('jf-machine')), isNull);
expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull);
expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull);
});
@@ -389,8 +390,8 @@ void main() {
final m = MultiServerManager();
addTearDown(m.dispose);
m.updateServerStatus('a', true);
m.updateServerStatus('b', false);
m.updateServerStatus(ServerId('a'), true);
m.updateServerStatus(ServerId('b'), false);
final emitted = <Map<String, bool>>[];
final sub = m.statusStream.listen(emitted.add);
@@ -414,7 +415,7 @@ void main() {
m.disconnectAll();
expect(m.getClient('jf-machine'), isNull);
expect(m.getClient(ServerId('jf-machine')), isNull);
expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull);
expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull);
});
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
@@ -68,7 +69,7 @@ class _RecordingMediaClient implements MediaServerClient {
_RecordingMediaClient({required this.serverId, required this.backend});
@override
final String serverId;
final ServerId serverId;
@override
final MediaBackend backend;
@@ -200,7 +201,7 @@ void main() {
});
// No SettingsService initialized, no client registered → default 90/100.
expect(svc.getWatchedThreshold('unknown-server'), 0.9);
expect(svc.getWatchedThreshold(ServerId('unknown-server')), 0.9);
});
});
@@ -220,7 +221,7 @@ void main() {
var notifications = 0;
svc.addListener(() => notifications++);
await svc.queueMarkWatched(serverId: 'srv', itemId: '42');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42');
expect(await svc.getPendingSyncCount(), 1);
// ChangeNotifier emission was synchronous in the queue helper.
@@ -242,7 +243,7 @@ void main() {
await db.close();
});
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '42');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '42');
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
@@ -257,13 +258,13 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '42');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42');
expect(await svc.getPendingSyncCount(), 1);
// The DB layer's insertWatchAction deletes any prior entries for the
// same globalKey before inserting — so flipping watched/unwatched keeps
// a single row.
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '42');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '42');
expect(await svc.getPendingSyncCount(), 1);
final action = await db.getLatestWatchAction('srv:42');
@@ -278,9 +279,9 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkWatched(serverId: 'srv', itemId: '2');
await svc.queueMarkUnwatched(serverId: 'other', itemId: '1');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '2');
await svc.queueMarkUnwatched(serverId: ServerId('other'), itemId: '1');
expect(await svc.getPendingSyncCount(), 3);
@@ -299,7 +300,7 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '42');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42');
await svc.syncPendingItems();
@@ -317,7 +318,7 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '42');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42');
var action = await db.getLatestWatchAction('srv:42');
for (var i = 0; i < OfflineWatchSyncService.maxSyncAttempts; i++) {
await db.updateSyncAttempt(action!.id, 'server error');
@@ -340,9 +341,9 @@ void main() {
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: 100000);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: 100000);
final queued = await db.getLatestWatchAction('srv:42');
await svc.syncPendingItems();
@@ -365,9 +366,9 @@ void main() {
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: null);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: null);
await svc.syncPendingItems();
@@ -389,9 +390,9 @@ void main() {
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 95000, duration: 100000);
final queued = await db.getLatestWatchAction('srv:42');
await svc.syncPendingItems();
@@ -423,7 +424,7 @@ void main() {
});
// 50% progress → below default 0.9 threshold.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50, duration: 100);
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
@@ -441,7 +442,7 @@ void main() {
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: null);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50, duration: null);
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
@@ -459,7 +460,7 @@ void main() {
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 95, duration: 100);
final action = await db.getLatestWatchAction('srv:42');
expect(action!.shouldMarkWatched, isTrue);
@@ -473,8 +474,8 @@ void main() {
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 10, duration: 100);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 20, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 10, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 20, duration: 100);
// upsertProgressAction merges by globalKey — only ONE row.
expect(await svc.getPendingSyncCount(), 1);
@@ -505,7 +506,7 @@ void main() {
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
expect(await svc.getLocalWatchStatus('srv:1'), isTrue);
});
@@ -516,7 +517,7 @@ void main() {
mgr.dispose();
await db.close();
});
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '1');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '1');
expect(await svc.getLocalWatchStatus('srv:1'), isFalse);
});
@@ -529,11 +530,11 @@ void main() {
});
// Below threshold is resume-only; it must not override stale watched metadata.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 50, duration: 100);
expect(await svc.getLocalWatchStatus('srv:1'), isNull);
// Above threshold → shouldMarkWatched=true → status=true.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '2', viewOffset: 99, duration: 100);
expect(await svc.getLocalWatchStatus('srv:2'), isTrue);
});
});
@@ -561,10 +562,10 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
expect(await svc.getLocalViewOffset('srv:1'), isNull);
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2');
expect(await svc.getLocalViewOffset('srv:2'), isNull);
});
@@ -576,7 +577,7 @@ void main() {
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 12345, duration: 60000);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 12345, duration: 60000);
expect(await svc.getLocalViewOffset('srv:1'), 12345);
});
@@ -588,13 +589,13 @@ void main() {
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 5000, duration: 10000);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 5000, duration: 10000);
expect(await svc.getLocalViewOffset('srv:1'), 5000);
// Manual "watched" wipes the progress row (insertWatchAction deletes
// by globalKey first), so getLocalViewOffset reads the new row whose
// actionType != 'progress' → null.
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
expect(await svc.getLocalViewOffset('srv:1'), isNull);
});
});
@@ -614,9 +615,9 @@ void main() {
expect(await svc.getPendingSyncCount(), 0);
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2');
await svc.queueProgressUpdate(serverId: 'srv', itemId: '3', viewOffset: 50, duration: 100);
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2');
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '3', viewOffset: 50, duration: 100);
expect(await svc.getPendingSyncCount(), 3);
});
@@ -628,8 +629,8 @@ void main() {
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 10, duration: 100);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 20, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 10, duration: 100);
await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 20, duration: 100);
expect(await svc.getPendingSyncCount(), 1);
});
});
@@ -657,10 +658,10 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2');
await svc.queueProgressUpdate(
serverId: 'srv',
serverId: ServerId('srv'),
itemId: '3',
viewOffset: 99,
duration: 100, // above threshold
@@ -691,7 +692,7 @@ void main() {
mgr.debugRegisterJellyfinClientForTesting(activeUserB);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -699,14 +700,14 @@ void main() {
status: 3,
);
await db.insertWatchAction(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
actionType: OfflineActionType.unwatched.id,
);
await Future<void>.delayed(const Duration(milliseconds: 2));
await db.insertWatchAction(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'item-1',
actionType: OfflineActionType.watched.id,
@@ -725,14 +726,14 @@ void main() {
});
svc.setActiveProfileId('profile-a');
await svc.queueMarkWatched(serverId: 'plex-machine', itemId: 'item-1');
await svc.queueMarkWatched(serverId: ServerId('plex-machine'), itemId: 'item-1');
expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isTrue);
expect(await svc.getPendingSyncCount(), 1);
svc.setActiveProfileId('profile-b');
expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isNull);
expect(await svc.getPendingSyncCount(), 0);
await svc.queueMarkUnwatched(serverId: 'plex-machine', itemId: 'item-1');
await svc.queueMarkUnwatched(serverId: ServerId('plex-machine'), itemId: 'item-1');
expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isFalse);
expect(await svc.getPendingSyncCount(), 1);
@@ -752,7 +753,7 @@ void main() {
});
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -760,7 +761,7 @@ void main() {
status: 3,
);
await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1');
await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1');
final queued = await db.getPendingWatchActions();
expect(queued.single.clientScopeId, 'jf-machine/user-a');
@@ -782,7 +783,7 @@ void main() {
mgr.debugRegisterJellyfinClientForTesting(activeUserB);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -790,7 +791,7 @@ void main() {
status: 3,
);
await db.upsertProgressAction(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
viewOffset: 5000,
@@ -799,7 +800,7 @@ void main() {
);
await Future<void>.delayed(const Duration(milliseconds: 2));
await db.upsertProgressAction(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'item-1',
viewOffset: 90000,
@@ -822,7 +823,7 @@ void main() {
});
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -837,7 +838,7 @@ void main() {
addTearDown(activeUserB.close);
mgr.debugRegisterJellyfinClientForTesting(activeUserB);
final returnedScope = await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1');
final returnedScope = await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1');
final queued = await db.getPendingWatchActions();
expect(returnedScope, 'jf-machine/user-b');
@@ -876,7 +877,7 @@ void main() {
addTearDown(userB.close);
mgr.debugRegisterJellyfinClientForTesting(userA);
await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1');
await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1');
final queued = await db.getPendingWatchActions();
expect(queued.single.clientScopeId, 'jf-machine/user-a');
@@ -915,7 +916,11 @@ void main() {
addTearDown(client.close);
mgr.debugRegisterJellyfinClientForTesting(client);
await db.insertWatchAction(serverId: 'jf-machine', ratingKey: 'item-1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(
serverId: ServerId('jf-machine'),
ratingKey: 'item-1',
actionType: OfflineActionType.watched.id,
);
await svc.syncPendingItems();
@@ -955,14 +960,18 @@ void main() {
addTearDown(userB.close);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
type: 'movie',
status: 3,
);
await db.insertWatchAction(serverId: 'jf-machine', ratingKey: 'item-1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(
serverId: ServerId('jf-machine'),
ratingKey: 'item-1',
actionType: OfflineActionType.watched.id,
);
mgr.debugRegisterJellyfinClientForTesting(userA);
mgr.debugRegisterJellyfinClientForTesting(userB);
@@ -1009,7 +1018,7 @@ void main() {
addTearDown(sub.cancel);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -1058,7 +1067,7 @@ void main() {
addTearDown(sub.cancel);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -1119,7 +1128,7 @@ void main() {
addTearDown(userB.close);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
globalKey: 'jf-machine:ep-1',
@@ -1157,7 +1166,7 @@ void main() {
addTearDown(userB.close);
await db.insertDownload(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
globalKey: 'jf-machine:item-1',
@@ -1187,8 +1196,8 @@ void main() {
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', itemId: '1');
await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2');
await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1');
await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2');
expect(await svc.getPendingSyncCount(), 2);
var notifications = 0;
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'dart:io';
import 'package:drift/drift.dart';
@@ -78,11 +79,21 @@ void main() {
});
test('pure-offline playback loads cached Plex media source info without a client', () async {
await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1');
await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope());
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1',
);
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'),
metadata: MediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
preferOffline: true,
);
@@ -93,12 +104,22 @@ void main() {
});
test('preferOffline uses cache without calling live client when local file exists', () async {
await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1');
await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope());
final client = _FailingPlaybackClient(serverId: 'srv-1');
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1',
);
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'),
metadata: MediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
preferOffline: true,
);
@@ -113,19 +134,24 @@ void main() {
test('pure-offline playback uses cached Plex media source for selected version', () async {
await _insertDownloaded(
db,
serverId: 'srv-1',
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v2',
mediaIndex: 1,
);
await PlexApiCache.instance.put(
'srv-1',
ServerId('srv-1'),
'/library/metadata/movie-1',
_plexMetadataEnvelope(includeSecondVersion: true),
);
final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'),
metadata: MediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 1,
preferOffline: true,
);
@@ -137,7 +163,7 @@ void main() {
test('offline path falls back to media index when caller has no source id', () async {
await _insertDownloaded(
db,
serverId: 'srv-1',
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1-v1',
mediaIndex: 0,
@@ -146,14 +172,17 @@ void main() {
final service = PlaybackInitializationService(database: db);
expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 1), null);
expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 0), 'content://offline/movie-1-v1');
expect(await service.getOfflineVideoPath(ServerId('srv-1'), 'movie-1', mediaIndex: 1), null);
expect(
await service.getOfflineVideoPath(ServerId('srv-1'), 'movie-1', mediaIndex: 0),
'content://offline/movie-1-v1',
);
});
test('pure-offline Jellyfin cache works without a connection row', () async {
await _insertDownloaded(
db,
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'item-1',
videoFilePath: 'content://offline/jf-item-1',
@@ -169,7 +198,12 @@ void main() {
);
final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'jf-machine'),
metadata: MediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: ServerId('jf-machine'),
),
selectedMediaIndex: 0,
preferOffline: true,
);
@@ -180,14 +214,24 @@ void main() {
});
test('SAF offline playback discovers app-managed sidecar subtitles', () async {
await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1');
final subtitlePath = await DownloadStorageService.instance.getSubtitlePath('srv-1', 'movie-1', 2, 'srt');
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'movie-1',
videoFilePath: 'content://offline/movie-1',
);
final subtitlePath = await DownloadStorageService.instance.getSubtitlePath(ServerId('srv-1'), 'movie-1', 2, 'srt');
final subtitleFile = File(subtitlePath);
await subtitleFile.parent.create(recursive: true);
await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello');
final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'),
metadata: MediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
preferOffline: true,
);
@@ -198,7 +242,7 @@ void main() {
});
test('cache-only playback extras fills missing Plex marker types from chapters', () async {
await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope());
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras(
backend: MediaBackend.plex,
@@ -211,7 +255,11 @@ void main() {
});
test('Plex extras parser skips malformed entries and keeps valid ones', () async {
await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope(malformedExtras: true));
await PlexApiCache.instance.put(
ServerId('srv-1'),
'/library/metadata/movie-1',
_plexMetadataEnvelope(malformedExtras: true),
);
final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras(
backend: MediaBackend.plex,
@@ -255,7 +303,7 @@ void main() {
});
test('cache-only Jellyfin playback extras uses chapter fallback patterns', () async {
await JellyfinApiCache.instance.put('srv-1/user-1', '/Users/user-1/Items/item-1', {
await JellyfinApiCache.instance.put(ServerId('srv-1/user-1'), '/Users/user-1/Items/item-1', {
'Id': 'item-1',
'Type': 'Episode',
'Name': 'Episode',
@@ -278,13 +326,13 @@ void main() {
});
test('cache-only Jellyfin playback extras uses cached native media segments', () async {
await JellyfinApiCache.instance.put('srv-1/user-1', '/Users/user-1/Items/item-1', {
await JellyfinApiCache.instance.put(ServerId('srv-1/user-1'), '/Users/user-1/Items/item-1', {
'Id': 'item-1',
'Type': 'Episode',
'Name': 'Episode',
'Chapters': [],
});
await JellyfinApiCache.instance.put('srv-1/user-1', '/MediaSegments/item-1', {
await JellyfinApiCache.instance.put(ServerId('srv-1/user-1'), '/MediaSegments/item-1', {
'Items': [
{'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000},
{'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000},
@@ -307,7 +355,7 @@ class _FailingPlaybackClient implements MediaServerClient {
_FailingPlaybackClient({required this.serverId});
@override
final String serverId;
final ServerId serverId;
int playbackInitializationCalls = 0;
@@ -323,7 +371,7 @@ class _FailingPlaybackClient implements MediaServerClient {
Future<void> _insertDownloaded(
AppDatabase db, {
required String serverId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String videoFilePath,
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -263,13 +264,14 @@ class _DelayedStartClient extends _FakePlexClient {
}
}
MediaItem _meta({String ratingKey = '42', String? serverId = 'srv', String? type = 'movie'}) => MediaItem(
id: ratingKey,
backend: MediaBackend.plex,
kind: MediaKind.fromString(type),
title: 'Test Item',
serverId: serverId,
);
MediaItem _meta({String ratingKey = '42', ServerId? serverId = const ServerId('srv'), String? type = 'movie'}) =>
MediaItem(
id: ratingKey,
backend: MediaBackend.plex,
kind: MediaKind.fromString(type),
title: 'Test Item',
serverId: serverId,
);
void main() {
setUp(resetSharedPreferencesForTest);
@@ -747,7 +749,7 @@ void main() {
final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 60));
final tracker = PlaybackProgressTracker(
client: null,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
metadata: _meta(ratingKey: '42', serverId: ServerId('srv')),
player: player,
isOffline: true,
offlineWatchService: svc,
@@ -798,7 +800,7 @@ void main() {
final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
metadata: _meta(ratingKey: '42', serverId: ServerId('srv')),
player: player,
isOffline: false,
offlineWatchService: svc,
@@ -825,7 +827,7 @@ void main() {
final player = _FakePlayer(position: const Duration(seconds: 30), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
metadata: _meta(ratingKey: '42', serverId: ServerId('srv')),
player: player,
isOffline: false,
);
@@ -851,7 +853,7 @@ void main() {
final player = _FakePlayer(position: Duration.zero, duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: 'no-watch', serverId: 'srv'),
metadata: _meta(ratingKey: 'no-watch', serverId: ServerId('srv')),
player: player,
isOffline: false,
);
@@ -875,7 +877,7 @@ void main() {
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: 'scrobbler', serverId: 'srv'),
metadata: _meta(ratingKey: 'scrobbler', serverId: ServerId('srv')),
player: player,
isOffline: false,
);
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_backend.dart';
@@ -21,7 +22,7 @@ class _PlaybackClient implements MediaServerClient {
final PlaybackInitializationResult result;
@override
String get serverId => 'srv';
ServerId get serverId => ServerId('srv');
@override
MediaBackend get backend => clientBackend;
+79 -78
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
@@ -61,27 +62,27 @@ void main() {
group('get / put', () {
test('miss returns null for an unknown key', () async {
expect(await cache.get('srv', '/library/metadata/1'), isNull);
expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull);
});
test('put + get round-trip preserves the JSON map', () async {
final payload = mediaContainer(ratingKey: '1', title: 'Hello');
await cache.put('srv', '/library/metadata/1', payload);
await cache.put(ServerId('srv'), '/library/metadata/1', payload);
final hit = await cache.get('srv', '/library/metadata/1');
final hit = await cache.get(ServerId('srv'), '/library/metadata/1');
expect(hit, isNotNull);
expect(hit, equals(payload));
});
test('put on existing key overwrites prior data (insertOnConflictUpdate)', () async {
await cache.put('srv', '/library/metadata/1', {
await cache.put(ServerId('srv'), '/library/metadata/1', {
'MediaContainer': {
'Metadata': [
{'title': 'first'},
],
},
});
await cache.put('srv', '/library/metadata/1', {
await cache.put(ServerId('srv'), '/library/metadata/1', {
'MediaContainer': {
'Metadata': [
{'title': 'second'},
@@ -89,22 +90,22 @@ void main() {
},
});
final hit = await cache.get('srv', '/library/metadata/1');
final hit = await cache.get(ServerId('srv'), '/library/metadata/1');
expect(((hit!['MediaContainer'] as Map)['Metadata'] as List).first['title'], 'second');
});
test('keys are namespaced by serverId — same endpoint on different servers is isolated', () async {
await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: 'A'));
await cache.put('srv-b', '/library/metadata/1', mediaContainer(ratingKey: 'B'));
await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: 'A'));
await cache.put(ServerId('srv-b'), '/library/metadata/1', mediaContainer(ratingKey: 'B'));
final a = await cache.get('srv-a', '/library/metadata/1');
final b = await cache.get('srv-b', '/library/metadata/1');
final a = await cache.get(ServerId('srv-a'), '/library/metadata/1');
final b = await cache.get(ServerId('srv-b'), '/library/metadata/1');
expect(((a!['MediaContainer'] as Map)['Metadata'] as List).first['ratingKey'], 'A');
expect(((b!['MediaContainer'] as Map)['Metadata'] as List).first['ratingKey'], 'B');
});
test('put writes a fresh cachedAt timestamp on overwrite', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
final firstRow = await (db.select(
db.apiCache,
)..where((t) => t.cacheKey.equals('srv:/library/metadata/1'))).getSingle();
@@ -112,7 +113,7 @@ void main() {
// Wait one tick so DateTime.now() advances.
await Future<void>.delayed(const Duration(milliseconds: 5));
await cache.put('srv', '/library/metadata/1', mediaContainer(title: 'Updated'));
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(title: 'Updated'));
final secondRow = await (db.select(
db.apiCache,
)..where((t) => t.cacheKey.equals('srv:/library/metadata/1'))).getSingle();
@@ -127,33 +128,33 @@ void main() {
group('deletion', () {
test('deleteForServer wipes only the targeted serverId', () async {
await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2'));
await cache.put('srv-b', '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2'));
await cache.put(ServerId('srv-b'), '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.deleteForServer('srv-a');
await cache.deleteForServer(ServerId('srv-a'));
expect(await cache.get('srv-a', '/library/metadata/1'), isNull);
expect(await cache.get('srv-a', '/library/metadata/2'), isNull);
expect(await cache.get('srv-b', '/library/metadata/1'), isNotNull);
expect(await cache.get(ServerId('srv-a'), '/library/metadata/1'), isNull);
expect(await cache.get(ServerId('srv-a'), '/library/metadata/2'), isNull);
expect(await cache.get(ServerId('srv-b'), '/library/metadata/1'), isNotNull);
});
test('deleteForItem removes both metadata and children endpoints', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer());
await cache.put('srv', '/library/metadata/1/children', mediaContainer());
await cache.put('srv', '/library/metadata/2', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/1/children', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/2', mediaContainer());
await cache.deleteForItem('srv', '1');
await cache.deleteForItem(ServerId('srv'), '1');
expect(await cache.get('srv', '/library/metadata/1'), isNull);
expect(await cache.get('srv', '/library/metadata/1/children'), isNull);
expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull);
expect(await cache.get(ServerId('srv'), '/library/metadata/1/children'), isNull);
// Unrelated item not affected.
expect(await cache.get('srv', '/library/metadata/2'), isNotNull);
expect(await cache.get(ServerId('srv'), '/library/metadata/2'), isNotNull);
});
test('clearAll wipes every row across servers', () async {
await cache.put('srv-a', '/library/metadata/1', mediaContainer());
await cache.put('srv-b', '/library/metadata/2', mediaContainer());
await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer());
await cache.put(ServerId('srv-b'), '/library/metadata/2', mediaContainer());
await cache.clearAll();
@@ -161,14 +162,14 @@ void main() {
});
test('clearVolatile preserves pinned offline metadata', () async {
await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2'));
await cache.pinForOffline('srv-a', '1');
await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2'));
await cache.pinForOffline(ServerId('srv-a'), '1');
await cache.clearVolatile();
expect(await cache.get('srv-a', '/library/metadata/1'), isNotNull);
expect(await cache.get('srv-a', '/library/metadata/2'), isNull);
expect(await cache.get(ServerId('srv-a'), '/library/metadata/1'), isNotNull);
expect(await cache.get(ServerId('srv-a'), '/library/metadata/2'), isNull);
});
});
@@ -178,43 +179,43 @@ void main() {
group('pinning', () {
test('isPinned defaults to false for a freshly cached item', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer());
expect(await cache.isPinnedRatingKey('srv', '1'), isFalse);
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isFalse);
});
test('isPinned returns false when the item is not cached at all', () async {
expect(await cache.isPinnedRatingKey('srv', 'missing'), isFalse);
expect(await cache.isPinnedRatingKey(ServerId('srv'), 'missing'), isFalse);
});
test('pinForOffline marks the row as pinned', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer());
await cache.pinForOffline('srv', '1');
expect(await cache.isPinnedRatingKey('srv', '1'), isTrue);
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
await cache.pinForOffline(ServerId('srv'), '1');
expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isTrue);
});
test('unpinForOffline reverts the pin', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer());
await cache.pinForOffline('srv', '1');
await cache.unpinForOffline('srv', '1');
expect(await cache.isPinnedRatingKey('srv', '1'), isFalse);
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
await cache.pinForOffline(ServerId('srv'), '1');
await cache.unpinForOffline(ServerId('srv'), '1');
expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isFalse);
});
test('pinForOffline on missing row is a no-op (no insert, no throw)', () async {
await cache.pinForOffline('srv', 'missing');
expect(await cache.isPinnedRatingKey('srv', 'missing'), isFalse);
await cache.pinForOffline(ServerId('srv'), 'missing');
expect(await cache.isPinnedRatingKey(ServerId('srv'), 'missing'), isFalse);
});
test('getPinnedKeys extracts ratingKeys from pinned rows for the server', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer());
await cache.put('srv', '/library/metadata/2', mediaContainer());
await cache.put('srv', '/library/metadata/3', mediaContainer());
await cache.put('other', '/library/metadata/4', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/2', mediaContainer());
await cache.put(ServerId('srv'), '/library/metadata/3', mediaContainer());
await cache.put(ServerId('other'), '/library/metadata/4', mediaContainer());
await cache.pinForOffline('srv', '1');
await cache.pinForOffline('srv', '3');
await cache.pinForOffline('other', '4');
await cache.pinForOffline(ServerId('srv'), '1');
await cache.pinForOffline(ServerId('srv'), '3');
await cache.pinForOffline(ServerId('other'), '4');
final keys = await cache.getPinnedKeys('srv');
final keys = await cache.getPinnedKeys(ServerId('srv'));
expect(keys, equals({'1', '3'}));
});
@@ -228,15 +229,15 @@ void main() {
const ApiCacheCompanion(pinned: Value(true)),
);
expect(await cache.getPinnedKeys('srv'), isEmpty);
expect(await cache.getPinnedKeys(ServerId('srv')), isEmpty);
});
test('getPinnedKeys handles alphanumeric ratingKeys', () async {
// Plex sometimes uses alphanumeric ratingKeys (e.g. for online-content).
await cache.put('srv', '/library/metadata/abc-123', mediaContainer(ratingKey: 'abc-123'));
await cache.pinForOffline('srv', 'abc-123');
await cache.put(ServerId('srv'), '/library/metadata/abc-123', mediaContainer(ratingKey: 'abc-123'));
await cache.pinForOffline(ServerId('srv'), 'abc-123');
final keys = await cache.getPinnedKeys('srv');
final keys = await cache.getPinnedKeys(ServerId('srv'));
expect(keys, equals({'abc-123'}));
});
});
@@ -247,20 +248,20 @@ void main() {
group('metadata extraction', () {
test('getMetadata returns null when the key is not cached', () async {
expect(await cache.getMetadata('srv', 'missing'), isNull);
expect(await cache.getMetadata(ServerId('srv'), 'missing'), isNull);
});
test('getMetadata returns null when cached payload has no Metadata array', () async {
await cache.put('srv', '/library/metadata/empty', {
await cache.put(ServerId('srv'), '/library/metadata/empty', {
'MediaContainer': {'size': 0},
});
expect(await cache.getMetadata('srv', 'empty'), isNull);
expect(await cache.getMetadata(ServerId('srv'), 'empty'), isNull);
});
test('getMetadata parses MediaContainer.Metadata[0] and tags it with serverId', () async {
await cache.put('srv', '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello'));
await cache.put(ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello'));
final meta = await cache.getMetadata('srv', '42');
final meta = await cache.getMetadata(ServerId('srv'), '42');
expect(meta, isNotNull);
expect(meta!.id, '42');
expect(meta.title, 'Hello');
@@ -269,12 +270,12 @@ void main() {
test('getMetadata preserves hoisted MediaContainer library fields', () async {
await cache.put(
'srv',
ServerId('srv'),
'/library/metadata/42',
mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: '7', librarySectionTitle: 'Movies'),
);
final meta = await cache.getMetadata('srv', '42');
final meta = await cache.getMetadata(ServerId('srv'), '42');
expect(meta, isNotNull);
expect(meta!.libraryId, '7');
@@ -282,21 +283,21 @@ void main() {
});
test('getAllPinnedMetadata returns an empty map when nothing is pinned', () async {
await cache.put('srv', '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(ratingKey: '1'));
// No pin yet.
expect(await cache.getAllPinnedMetadata(), isEmpty);
});
test('getAllPinnedMetadata aggregates pinned items across servers, keyed by globalKey', () async {
await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1', title: 'A1'));
await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2', title: 'A2'));
await cache.put('srv-b', '/library/metadata/9', mediaContainer(ratingKey: '9', title: 'B9'));
await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1', title: 'A1'));
await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2', title: 'A2'));
await cache.put(ServerId('srv-b'), '/library/metadata/9', mediaContainer(ratingKey: '9', title: 'B9'));
// One unpinned row to verify it's filtered out.
await cache.put('srv-b', '/library/metadata/10', mediaContainer(ratingKey: '10', title: 'B10'));
await cache.put(ServerId('srv-b'), '/library/metadata/10', mediaContainer(ratingKey: '10', title: 'B10'));
await cache.pinForOffline('srv-a', '1');
await cache.pinForOffline('srv-a', '2');
await cache.pinForOffline('srv-b', '9');
await cache.pinForOffline(ServerId('srv-a'), '1');
await cache.pinForOffline(ServerId('srv-a'), '2');
await cache.pinForOffline(ServerId('srv-b'), '9');
final result = await cache.getAllPinnedMetadata();
expect(result.keys.toSet(), {'srv-a:1', 'srv-a:2', 'srv-b:9'});
@@ -308,11 +309,11 @@ void main() {
test('getAllPinnedMetadata preserves hoisted MediaContainer library fields', () async {
await cache.put(
'srv',
ServerId('srv'),
'/library/metadata/42',
mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: 7, librarySectionTitle: 'Movies'),
);
await cache.pinForOffline('srv', '42');
await cache.pinForOffline(ServerId('srv'), '42');
final result = await cache.getAllPinnedMetadata();
@@ -331,8 +332,8 @@ void main() {
pinned: const Value(true),
),
);
await cache.put('srv', '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.pinForOffline('srv', '1');
await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(ratingKey: '1'));
await cache.pinForOffline(ServerId('srv'), '1');
final result = await cache.getAllPinnedMetadata();
expect(result.keys.toSet(), {'srv:1'});
@@ -350,8 +351,8 @@ void main() {
),
);
// Good pinned row.
await cache.put('srv', '/library/metadata/good', mediaContainer(ratingKey: 'good', title: 'OK'));
await cache.pinForOffline('srv', 'good');
await cache.put(ServerId('srv'), '/library/metadata/good', mediaContainer(ratingKey: 'good', title: 'OK'));
await cache.pinForOffline(ServerId('srv'), 'good');
final result = await cache.getAllPinnedMetadata();
expect(result.keys, contains('srv:good'));
+8 -7
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:plezy/media/ids.dart';
import 'dart:convert';
import 'package:drift/native.dart';
@@ -46,7 +47,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
);
@@ -81,7 +82,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
@@ -113,7 +114,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
@@ -146,7 +147,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
seedTranscoderVideoSupport: true,
@@ -178,7 +179,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
seedTranscoderVideoSupport: true,
@@ -209,7 +210,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
);
@@ -244,7 +245,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
serverName: 'Server',
httpClient: httpClient,
prioritizedEndpoints: const [primary, fallback],
+2 -1
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -31,7 +32,7 @@ void main() {
product: 'Plezy',
version: '1',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
httpClient: MockClient(handler),
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
@@ -39,7 +40,7 @@ void main() {
version: '1',
machineIdentifier: 'machine-1',
),
serverId: 'machine-1',
serverId: ServerId('machine-1'),
httpClient: MockClient(handler),
epgProviders: epgProviders,
);
+21 -20
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/plex_mappers.dart';
@@ -60,7 +61,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId, serverName: _serverName);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(item.id, '12345');
expect(item.backend, MediaBackend.plex);
@@ -132,7 +133,7 @@ void main() {
'year': 2008,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.show);
expect(item.leafCount, 62);
expect(item.viewedLeafCount, 62);
@@ -150,7 +151,7 @@ void main() {
'flattenSeasons': '1',
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.raw, containsPair('key', '/library/metadata/500'));
expect(item.raw, containsPair('skipChildren', true));
@@ -170,7 +171,7 @@ void main() {
'viewedLeafCount': 3,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.season);
expect(item.index, 1);
expect(item.parentId, '500');
@@ -199,7 +200,7 @@ void main() {
'viewOffset': 1410000,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.episode);
expect(item.index, 1);
expect(item.parentIndex, 1);
@@ -228,7 +229,7 @@ void main() {
'leafCount': 13,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.album);
expect(item.title, 'Random Access Memories');
expect(item.parentId, '699');
@@ -251,7 +252,7 @@ void main() {
'duration': 369000,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.track);
expect(item.title, 'Get Lucky');
expect(item.index, 8);
@@ -285,7 +286,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.mediaVersions, isNotNull);
final v = item.mediaVersions!.single;
expect(v.id, '1');
@@ -310,7 +311,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId);
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.clearLogoPath, '/library/metadata/12345/clearLogo');
expect(item.backgroundSquarePath, '/library/metadata/12345/squareBg');
});
@@ -329,7 +330,7 @@ void main() {
'hidden': 0,
};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId, serverName: _serverName);
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(lib.id, '1');
expect(lib.backend, MediaBackend.plex);
expect(lib.title, 'Movies');
@@ -345,13 +346,13 @@ void main() {
test('shared library is marked isShared', () {
final json = {'key': 'shared', 'title': 'Shared with you', 'type': 'movie'};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId, isShared: true);
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), isShared: true);
expect(lib.isShared, isTrue);
});
test('hidden=1 maps to true', () {
final json = {'key': '2', 'title': 'Hidden', 'type': 'show', 'hidden': 1};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId);
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId));
expect(lib.hidden, isTrue);
});
@@ -384,7 +385,7 @@ void main() {
// PlexLibraryDto.fromJson would throw TypeError when Plex omitted
// either field. Confirms graceful degradation.
final json = {'key': '99'};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId);
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId));
expect(lib.id, '99');
expect(lib.title, '');
expect(lib.kind, MediaKind.unknown);
@@ -406,7 +407,7 @@ void main() {
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId, serverName: _serverName);
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(hub.id, '/hubs/movie.recentlyAdded');
expect(hub.identifier, 'movie.recentlyAdded.1');
expect(hub.title, 'Recently Added Movies');
@@ -436,7 +437,7 @@ void main() {
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId);
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId));
expect(hub.items.length, 2);
expect(hub.items[0].kind, MediaKind.show);
expect(hub.items[1].kind, MediaKind.unknown);
@@ -455,7 +456,7 @@ void main() {
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId);
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId));
expect(hub.items.length, 2);
expect(hub.items[0].kind, MediaKind.movie);
expect(hub.items[1].kind, MediaKind.show);
@@ -482,7 +483,7 @@ void main() {
'thumb': '/playlists/999/thumb',
};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId, serverName: _serverName);
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(p.id, '999');
expect(p.backend, MediaBackend.plex);
expect(p.title, 'Date Night');
@@ -511,7 +512,7 @@ void main() {
'playlistType': 'audio',
};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId);
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
expect(p.smart, isTrue);
expect(p.playlistType, 'audio');
});
@@ -521,7 +522,7 @@ void main() {
// PlexPlaylistDto.fromJson would throw TypeError when Plex omitted
// optional fields. Confirms graceful degradation.
final json = {'ratingKey': '777', 'title': 'Bare', 'summary': null};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId);
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
expect(p.id, '777');
expect(p.title, 'Bare');
expect(p.smart, isFalse);
@@ -562,7 +563,7 @@ void main() {
group('PlexMappers DTO direct entry points', () {
test('mediaItem (DTO) preserves data identical to JSON path', () {
final json = {'ratingKey': '1', 'type': 'movie', 'title': 'Test', 'year': 2024};
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: _serverId);
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: ServerId(_serverId));
final item = PlexMappers.mediaItem(dto);
expect(item.id, '1');
expect(item.title, 'Test');
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -37,7 +38,7 @@ void main() {
product: 'Plezy',
version: '1',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
httpClient: MockClient(handler),
);
}
@@ -166,7 +167,7 @@ void main() {
test('latest server metadata overwrites cached playback media fields', () async {
final cache = PlexApiCache.instance;
await cache.put('server-id', '/library/metadata/42', {
await cache.put(ServerId('server-id'), '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
@@ -194,7 +195,7 @@ void main() {
},
});
await cache.put('server-id', '/library/metadata/42', {
await cache.put(ServerId('server-id'), '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
@@ -214,7 +215,7 @@ void main() {
},
});
final cached = await cache.get('server-id', '/library/metadata/42');
final cached = await cache.get(ServerId('server-id'), '/library/metadata/42');
final metadata = (cached!['MediaContainer'] as Map<String, dynamic>)['Metadata'] as List<dynamic>;
final item = metadata.single as Map<String, dynamic>;
final media = item['Media'] as List<dynamic>;
@@ -228,7 +229,7 @@ void main() {
});
test('network failure falls back to lean cached playback metadata', () async {
await PlexApiCache.instance.put('server-id', '/library/metadata/42', {
await PlexApiCache.instance.put(ServerId('server-id'), '/library/metadata/42', {
'MediaContainer': {
'Metadata': [
{
+2 -1
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -32,7 +33,7 @@ void main() {
product: 'Plezy',
version: 'test',
),
serverId: 'plex-1',
serverId: ServerId('plex-1'),
serverName: 'Plex',
httpClient: MockClient(handler),
);
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'dart:io';
import 'package:drift/native.dart';
@@ -84,7 +85,7 @@ PlexClient _makeClient(Map<String, dynamic> rootContainer) {
product: 'Plezy',
version: 'test',
),
serverId: 'server-id',
serverId: ServerId('server-id'),
httpClient: MockClient((request) async {
expect(request.url.path, '/');
return http.Response(
+17 -16
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
@@ -78,21 +79,21 @@ void main() {
group('ServerEndpoint', () {
test('round-trip per server id', () async {
final s = await StorageService.getInstance();
await s.saveServerEndpoint('srv-1', 'http://192.0.2.1:32400');
await s.saveServerEndpoint('srv-2', 'http://198.51.100.5:32400');
await s.saveServerEndpoint(ServerId('srv-1'), 'http://192.0.2.1:32400');
await s.saveServerEndpoint(ServerId('srv-2'), 'http://198.51.100.5:32400');
expect(s.getServerEndpoint('srv-1'), 'http://192.0.2.1:32400');
expect(s.getServerEndpoint('srv-2'), 'http://198.51.100.5:32400');
expect(s.getServerEndpoint('missing'), isNull);
expect(s.getServerEndpoint(ServerId('srv-1')), 'http://192.0.2.1:32400');
expect(s.getServerEndpoint(ServerId('srv-2')), 'http://198.51.100.5:32400');
expect(s.getServerEndpoint(ServerId('missing')), isNull);
});
test('clearServerEndpoint removes only the targeted id', () async {
final s = await StorageService.getInstance();
await s.saveServerEndpoint('srv-1', 'http://example.test');
await s.saveServerEndpoint('srv-2', 'http://other.test');
await s.clearServerEndpoint('srv-1');
expect(s.getServerEndpoint('srv-1'), isNull);
expect(s.getServerEndpoint('srv-2'), 'http://other.test');
await s.saveServerEndpoint(ServerId('srv-1'), 'http://example.test');
await s.saveServerEndpoint(ServerId('srv-2'), 'http://other.test');
await s.clearServerEndpoint(ServerId('srv-1'));
expect(s.getServerEndpoint(ServerId('srv-1')), isNull);
expect(s.getServerEndpoint(ServerId('srv-2')), 'http://other.test');
});
});
@@ -123,16 +124,16 @@ void main() {
// Write legacy values directly — the setters are gone.
await s.prefs.setString('servers_list', '[{"x":1}]');
await s.prefs.setString('server_order', json.encode(['a', 'b']));
await s.saveServerEndpoint('a', 'http://foo.test');
await s.saveServerEndpoint('b', 'http://bar.test');
await s.saveServerEndpoint(ServerId('a'), 'http://foo.test');
await s.saveServerEndpoint(ServerId('b'), 'http://bar.test');
await s.clearMultiServerData();
// ignore: deprecated_member_use_from_same_package
expect(s.getServersListJson(), isNull);
expect(s.prefs.getString('server_order'), isNull);
expect(s.getServerEndpoint('a'), isNull);
expect(s.getServerEndpoint('b'), isNull);
expect(s.getServerEndpoint(ServerId('a')), isNull);
expect(s.getServerEndpoint(ServerId('b')), isNull);
});
});
@@ -404,7 +405,7 @@ void main() {
await s.prefs.setString('client_identifier', 'client-x');
await s.prefs.setString('servers_list', '[{"x":1}]');
await s.prefs.setString('server_order', json.encode(['a']));
await s.saveServerEndpoint('a', 'http://foo.test');
await s.saveServerEndpoint(ServerId('a'), 'http://foo.test');
// Library prefs and unrelated counters: write WITHOUT an active profile id
// so they land on the legacy unscoped key.
@@ -427,7 +428,7 @@ void main() {
// ignore: deprecated_member_use_from_same_package
expect(s.getServersListJson(), isNull);
expect(s.prefs.getString('server_order'), isNull);
expect(s.getServerEndpoint('a'), isNull);
expect(s.getServerEndpoint(ServerId('a')), isNull);
// Library prefs and unrelated state untouched (no scope active, so
// the scoped read falls through to the same legacy key it was written to).
+10 -9
View File
@@ -1,4 +1,5 @@
import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@@ -78,21 +79,21 @@ void main() {
await db.insertSyncRule(
profileId: 'profile-a',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
ratingKey: 'show-1',
globalKey: 'profile-a|jf-machine:show-1',
targetType: 'show',
episodeCount: 1,
);
await db.insertWatchAction(
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-b',
ratingKey: 'ep-1',
actionType: OfflineActionType.watched.id,
);
await db.insertWatchAction(
profileId: 'profile-b',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
actionType: OfflineActionType.watched.id,
@@ -154,7 +155,7 @@ void main() {
await db.insertSyncRule(
profileId: 'profile-a',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
ratingKey: 'show-1',
globalKey: 'profile-a|jf-machine:show-1',
targetType: 'show',
@@ -162,7 +163,7 @@ void main() {
);
await db.insertWatchAction(
profileId: 'profile-a',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
clientScopeId: 'jf-machine/user-a',
ratingKey: 'ep-1',
actionType: OfflineActionType.watched.id,
@@ -209,7 +210,7 @@ void main() {
await db.insertSyncRule(
profileId: 'profile-a',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
ratingKey: 'show-1',
globalKey: 'profile-a|jf-machine:show-1',
targetType: 'show',
@@ -266,7 +267,7 @@ void main() {
await db.insertSyncRule(
profileId: 'profile-b',
serverId: 'jf-machine',
serverId: ServerId('jf-machine'),
ratingKey: 'show-1',
globalKey: 'profile-b|jf-machine:show-1',
targetType: 'show',
@@ -316,7 +317,7 @@ void main() {
await db.insertSyncRule(
profileId: 'profile-a',
serverId: 'plex-machine',
serverId: ServerId('plex-machine'),
ratingKey: 'collection-1',
globalKey: ruleKey,
targetType: 'collection',
@@ -350,7 +351,7 @@ class _CollectionPagingClient implements MediaServerClient {
final collectionPageCalls = <({int? start, int? size})>[];
@override
String get serverId => 'plex-machine';
ServerId get serverId => ServerId('plex-machine');
@override
String? get serverName => 'Plex';
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
@@ -22,7 +23,7 @@ import 'package:plezy/utils/external_ids.dart';
class _FakeMediaServerClient implements MediaServerClient {
@override
final String serverId;
final ServerId serverId;
@override
String? get serverName => null;
@@ -35,7 +36,7 @@ class _FakeMediaServerClient implements MediaServerClient {
final double watchedThreshold;
_FakeMediaServerClient({
this.serverId = 'server-1',
this.serverId = const ServerId('server-1'),
required this.externalIdsByItem,
required this.descendantsByParent,
this.watchedThreshold = 0.9,
@@ -91,7 +92,7 @@ MediaItem _season() => MediaItem(
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season 1',
serverId: 'server-1',
serverId: ServerId('server-1'),
libraryId: 'lib-1',
index: 1,
parentId: 'show-1',
@@ -102,7 +103,7 @@ MediaItem _episode(int number, {int season = 1}) => MediaItem(
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $number',
serverId: 'server-1',
serverId: ServerId('server-1'),
libraryId: 'lib-1',
parentIndex: season,
index: number,
@@ -113,7 +114,7 @@ MediaItem _show() => MediaItem(
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'Show 1',
serverId: 'server-1',
serverId: ServerId('server-1'),
libraryId: 'lib-1',
);
@@ -122,7 +123,7 @@ MediaItem _movie() => MediaItem(
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie 1',
serverId: 'server-1',
serverId: ServerId('server-1'),
libraryId: 'lib-1',
);
@@ -490,17 +491,21 @@ void main() {
);
final firstClient = _FakeMediaServerClient(
serverId: 'server-a',
serverId: ServerId('server-a'),
externalIdsByItem: {'show-a': const ExternalIds(tvdb: 111)},
descendantsByParent: const {},
);
final secondClient = _FakeMediaServerClient(
serverId: 'server-b',
serverId: ServerId('server-b'),
externalIdsByItem: {'show-b': const ExternalIds(tvdb: 222)},
descendantsByParent: const {},
);
final firstEpisode = _episode(1).copyWith(id: 'episode-a', serverId: 'server-a', grandparentId: 'show-a');
final secondEpisode = _episode(1).copyWith(id: 'episode-b', serverId: 'server-b', grandparentId: 'show-b');
final firstEpisode = _episode(
1,
).copyWith(id: 'episode-a', serverId: ServerId('server-a'), grandparentId: 'show-a');
final secondEpisode = _episode(
1,
).copyWith(id: 'episode-b', serverId: ServerId('server-b'), grandparentId: 'show-b');
await coordinator.startPlayback(firstEpisode, firstClient);
await coordinator.startPlayback(secondEpisode, secondClient);
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/services/watch_state_resolver.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
@@ -52,7 +53,7 @@ void main() {
final snapshot = WatchStateResolver.fromEvent(
WatchStateEvent(
itemId: 'item-1',
serverId: 'srv',
serverId: ServerId('srv'),
changeType: WatchStateChangeType.progressUpdate,
parentChain: const [],
mediaType: 'movie',
@@ -70,7 +71,7 @@ void main() {
final snapshot = WatchStateResolver.fromEvent(
WatchStateEvent(
itemId: 'item-1',
serverId: 'srv',
serverId: ServerId('srv'),
changeType: WatchStateChangeType.removedFromContinueWatching,
parentChain: const [],
mediaType: 'movie',
+6 -5
View File
@@ -1,16 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/utils/global_key_utils.dart';
void main() {
group('buildGlobalKey', () {
test('joins with a colon', () {
expect(buildGlobalKey('server', '123'), 'server:123');
expect(buildGlobalKey(ServerId('server'), '123'), 'server:123');
});
test('passes through empty components', () {
expect(buildGlobalKey('', '123'), ':123');
expect(buildGlobalKey('server', ''), 'server:');
expect(buildGlobalKey('', ''), ':');
expect(buildGlobalKey(ServerId(''), '123'), ':123');
expect(buildGlobalKey(ServerId('server'), ''), 'server:');
expect(buildGlobalKey(ServerId(''), ''), ':');
});
});
@@ -51,7 +52,7 @@ void main() {
test('round-trip build → parse returns original components', () {
for (final pair in const [('s1', '42'), ('serverXYZ', '/library/metadata/123'), ('', 'abc'), ('s', '')]) {
final built = buildGlobalKey(pair.$1, pair.$2);
final built = buildGlobalKey(ServerId(pair.$1), pair.$2);
final parsed = parseGlobalKey(built);
expect(parsed, isNotNull);
expect(parsed!.serverId, pair.$1);
+7 -6
View File
@@ -1,10 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/utils/live_tv_grouping.dart';
LiveTvChannel _channel({
required String key,
required String serverId,
required ServerId serverId,
required String serverName,
required String dvrKey,
required String favoriteSource,
@@ -24,7 +25,7 @@ void main() {
test('groups channels by Live TV source while preserving first source appearance', () {
final firstHome = _channel(
key: '101',
serverId: 'home',
serverId: ServerId('home'),
serverName: 'Home Plex',
dvrKey: 'dvr-a',
favoriteSource: 'server://home/provider-a',
@@ -32,7 +33,7 @@ void main() {
);
final cabin = _channel(
key: '101',
serverId: 'cabin',
serverId: ServerId('cabin'),
serverName: 'Cabin Plex',
dvrKey: 'dvr-a',
favoriteSource: 'server://cabin/provider-b',
@@ -40,7 +41,7 @@ void main() {
);
final secondHome = _channel(
key: '102',
serverId: 'home',
serverId: ServerId('home'),
serverName: 'Home Plex',
dvrKey: 'dvr-a',
favoriteSource: 'server://home/provider-a',
@@ -58,7 +59,7 @@ void main() {
final channels = [
_channel(
key: '101',
serverId: 'home',
serverId: ServerId('home'),
serverName: 'Home Plex',
dvrKey: 'dvr-a',
favoriteSource: 'server://home/provider-a',
@@ -66,7 +67,7 @@ void main() {
),
_channel(
key: '101',
serverId: 'home',
serverId: ServerId('home'),
serverName: 'Home Plex',
dvrKey: 'dvr-b',
favoriteSource: 'server://home/provider-a',
+9 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
@@ -6,7 +7,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/utils/media_hub_ordering.dart';
MediaLibrary _library(String id, {String serverId = 'server'}) {
MediaLibrary _library(String id, {ServerId serverId = const ServerId('server')}) {
return MediaLibrary(
id: id,
backend: MediaBackend.plex,
@@ -16,11 +17,16 @@ MediaLibrary _library(String id, {String serverId = 'server'}) {
);
}
MediaItem _item(String id, {String? libraryId, String? serverId = 'server'}) {
MediaItem _item(String id, {String? libraryId, ServerId? serverId = const ServerId('server')}) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, libraryId: libraryId, serverId: serverId);
}
MediaHub _hub(String id, {String? libraryId, String? serverId = 'server', List<MediaItem> items = const []}) {
MediaHub _hub(
String id, {
String? libraryId,
ServerId? serverId = const ServerId('server'),
List<MediaItem> items = const [],
}) {
return MediaHub(id: id, title: id, type: 'movie', libraryId: libraryId, serverId: serverId, items: items);
}
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
@@ -74,7 +75,7 @@ void main() {
var notified = 0;
p.addListener(() => notified++);
// Without a session, setCurrentMedia logs a warning and bails — no notify.
p.setCurrentMedia(ratingKey: 'rk1', serverId: 's1', mediaTitle: 't1');
p.setCurrentMedia(ratingKey: 'rk1', serverId: ServerId('s1'), mediaTitle: 't1');
expect(notified, 0);
expect(p.currentMediaRatingKey, isNull);
p.dispose();
@@ -93,7 +94,7 @@ void main() {
test('markCurrentPlaybackHandled does not throw on a fresh provider', () {
final p = WatchTogetherProvider();
expect(() => p.markCurrentPlaybackHandled(ratingKey: 'rk1', serverId: 's1'), returnsNormally);
expect(() => p.markCurrentPlaybackHandled(ratingKey: 'rk1', serverId: ServerId('s1')), returnsNormally);
p.dispose();
});
+19 -8
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
@@ -30,7 +31,7 @@ DownloadTreeNode _showNode({required String key, required List<DownloadTreeNode>
MediaItem _episodeMeta({
required String id,
required String? serverId,
required ServerId? serverId,
required String? grandparentId,
required String? parentId,
}) => MediaItem(
@@ -49,7 +50,9 @@ void main() {
final ep = _episodeNode('plex1:ep100');
final season = _seasonNode(key: 'show42:season7', children: [ep]);
final show = _showNode(key: 'show42', children: [season]);
final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')};
final metadata = {
'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '7'),
};
expect(resolveDownloadContainerGlobalKey(show, metadata), 'plex1:42');
});
@@ -57,7 +60,9 @@ void main() {
test('season node: builds globalKey from leaf serverId + parentId', () {
final ep = _episodeNode('plex1:ep100');
final season = _seasonNode(key: 'show42:season7', children: [ep]);
final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')};
final metadata = {
'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '7'),
};
expect(resolveDownloadContainerGlobalKey(season, metadata), 'plex1:7');
});
@@ -70,7 +75,9 @@ void main() {
type: DownloadNodeType.movie,
status: DownloadStatus.completed,
);
final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')};
final metadata = {
'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '7'),
};
expect(resolveDownloadContainerGlobalKey(ep, metadata), isNull);
expect(resolveDownloadContainerGlobalKey(movie, metadata), isNull);
@@ -97,14 +104,18 @@ void main() {
test('show node with leaf missing grandparentId returns null', () {
final ep = _episodeNode('plex1:ep100');
final show = _showNode(key: 'show42', children: [ep]);
final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: null, parentId: '7')};
final metadata = {
'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: null, parentId: '7'),
};
expect(resolveDownloadContainerGlobalKey(show, metadata), isNull);
});
test('season node with leaf missing parentId returns null', () {
final ep = _episodeNode('plex1:ep100');
final season = _seasonNode(key: 'show42:season7', children: [ep]);
final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: null)};
final metadata = {
'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: null),
};
expect(resolveDownloadContainerGlobalKey(season, metadata), isNull);
});
@@ -115,8 +126,8 @@ void main() {
final s2 = _seasonNode(key: 'show42:season2', children: [ep2]);
final show = _showNode(key: 'show42', children: [s1, s2]);
final metadata = {
'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '1'),
'plex1:ep200': _episodeMeta(id: '200', serverId: 'plex1', grandparentId: '42', parentId: '2'),
'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '1'),
'plex1:ep200': _episodeMeta(id: '200', serverId: ServerId('plex1'), grandparentId: '42', parentId: '2'),
};
expect(resolveDownloadContainerGlobalKey(show, metadata), 'plex1:42');
+5 -4
View File
@@ -1,4 +1,5 @@
import 'dart:ui' show PointerDeviceKind;
import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -40,7 +41,7 @@ const _testTokens = MonoTokens(
MediaLibrary _library({
required String id,
required String title,
required String serverId,
required ServerId serverId,
required String serverName,
}) {
return MediaLibrary(
@@ -374,19 +375,19 @@ void main() {
final visibleServerALibrary = _library(
id: '1',
title: 'Visible Server A',
serverId: 'server-a',
serverId: ServerId('server-a'),
serverName: 'Server A',
);
final hiddenServerALibrary = _library(
id: '2',
title: 'Hidden Server A',
serverId: 'server-a',
serverId: ServerId('server-a'),
serverName: 'Server A',
);
final visibleServerBLibrary = _library(
id: '1',
title: 'Visible Server B',
serverId: 'server-b',
serverId: ServerId('server-b'),
serverName: 'Server B',
);