680 lines
22 KiB
Dart
680 lines
22 KiB
Dart
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';
|
|
import 'package:plezy/connection/connection.dart';
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/media/library_query.dart';
|
|
import 'package:plezy/media/media_backend.dart';
|
|
import 'package:plezy/media/media_item.dart';
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/media/media_server_client.dart';
|
|
import 'package:plezy/media/server_capabilities.dart';
|
|
import 'package:plezy/models/download_models.dart';
|
|
import 'package:plezy/services/jellyfin_api_cache.dart';
|
|
import 'package:plezy/services/jellyfin_client.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:plezy/services/sync_rule_executor.dart';
|
|
|
|
import '../test_helpers/backend_client_fixtures.dart';
|
|
import '../test_helpers/prefs.dart';
|
|
import '../test_helpers/media_items.dart';
|
|
|
|
JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
|
|
machineId: 'jf-machine',
|
|
userId: userId,
|
|
serverName: 'Shared JF',
|
|
userName: userId,
|
|
accessToken: 'token-$userId',
|
|
deviceId: 'device',
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
|
);
|
|
|
|
void main() {
|
|
setUp(resetSharedPreferencesForTest);
|
|
|
|
test('profile-scoped Jellyfin sync rule executes through active client', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
JellyfinApiCache.initialize(db);
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final pathsByUser = <String, List<String>>{'user-a': [], 'user-b': []};
|
|
|
|
JellyfinClient clientFor(String userId) {
|
|
return JellyfinClient.forTesting(
|
|
connection: _jellyfinConnection(userId),
|
|
httpClient: MockClient((request) async {
|
|
pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}');
|
|
if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/show-1') {
|
|
return http.Response(
|
|
'{"Id":"show-1","Type":"Series","Name":"Show $userId","RecursiveItemCount":1}',
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.method == 'GET' && request.url.path == '/Items') {
|
|
return http.Response(
|
|
'{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","SeriesId":"show-1","UserData":{"PlayCount":0}}]}',
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('not found', 404);
|
|
}),
|
|
);
|
|
}
|
|
|
|
final userA = clientFor('user-a');
|
|
final userB = clientFor('user-b');
|
|
addTearDown(userA.close);
|
|
addTearDown(userB.close);
|
|
|
|
manager.debugRegisterJellyfinClientForTesting(userB, online: false);
|
|
manager.debugRegisterJellyfinClientForTesting(userA);
|
|
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('jf-machine'),
|
|
ratingKey: 'show-1',
|
|
globalKey: 'profile-a|jf-machine:show-1',
|
|
targetType: 'show',
|
|
episodeCount: 1,
|
|
);
|
|
await db.insertWatchAction(
|
|
serverId: ServerId('jf-machine'),
|
|
clientScopeId: 'jf-machine/user-b',
|
|
ratingKey: 'ep-1',
|
|
actionType: OfflineActionType.watched.id,
|
|
);
|
|
await db.insertWatchAction(
|
|
profileId: 'profile-b',
|
|
serverId: ServerId('jf-machine'),
|
|
clientScopeId: 'jf-machine/user-a',
|
|
ratingKey: 'ep-1',
|
|
actionType: OfflineActionType.watched.id,
|
|
);
|
|
|
|
final queued = <({MediaItem item, MediaServerClient client})>[];
|
|
final executor = SyncRuleExecutor(database: db);
|
|
final results = await executor.executeSyncRules(
|
|
profileId: 'profile-a',
|
|
serverManager: manager,
|
|
downloads: const {},
|
|
metadata: const {},
|
|
associateDownload: (_, _) async {},
|
|
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
|
queued.add((item: item, client: client));
|
|
return true;
|
|
},
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results.single.queuedCount, 1);
|
|
expect(queued.single.client, same(userA));
|
|
expect(pathsByUser['user-a']!.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
|
|
expect(pathsByUser['user-b'], isEmpty);
|
|
});
|
|
|
|
test('profile-scoped sync rule excludes only the active profile local watched actions', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
JellyfinApiCache.initialize(db);
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final paths = <String>[];
|
|
final userA = JellyfinClient.forTesting(
|
|
connection: _jellyfinConnection('user-a'),
|
|
httpClient: MockClient((request) async {
|
|
paths.add('${request.method} ${request.url.path}?${request.url.query}');
|
|
if (request.method == 'GET' && request.url.path == '/Users/user-a/Items/show-1') {
|
|
return http.Response(
|
|
'{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}',
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.method == 'GET' && request.url.path == '/Items') {
|
|
return http.Response(
|
|
'{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}',
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('not found', 404);
|
|
}),
|
|
);
|
|
addTearDown(userA.close);
|
|
manager.debugRegisterJellyfinClientForTesting(userA);
|
|
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('jf-machine'),
|
|
ratingKey: 'show-1',
|
|
globalKey: 'profile-a|jf-machine:show-1',
|
|
targetType: 'show',
|
|
episodeCount: 1,
|
|
);
|
|
await db.insertWatchAction(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('jf-machine'),
|
|
clientScopeId: 'jf-machine/user-a',
|
|
ratingKey: 'ep-1',
|
|
actionType: OfflineActionType.watched.id,
|
|
);
|
|
|
|
final queued = <MediaItem>[];
|
|
final executor = SyncRuleExecutor(database: db);
|
|
final results = await executor.executeSyncRules(
|
|
profileId: 'profile-a',
|
|
serverManager: manager,
|
|
downloads: const {},
|
|
metadata: const {},
|
|
associateDownload: (_, _) async {},
|
|
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
|
queued.add(item);
|
|
return true;
|
|
},
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results, isEmpty);
|
|
expect(queued, isEmpty);
|
|
expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
|
|
});
|
|
|
|
test('profile-scoped Jellyfin sync rule does not execute for another profile', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
JellyfinApiCache.initialize(db);
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final paths = <String>[];
|
|
final userB = JellyfinClient.forTesting(
|
|
connection: _jellyfinConnection('user-b'),
|
|
httpClient: MockClient((request) async {
|
|
paths.add('${request.method} ${request.url.path}?${request.url.query}');
|
|
return http.Response('not found', 404);
|
|
}),
|
|
);
|
|
addTearDown(userB.close);
|
|
manager.debugRegisterJellyfinClientForTesting(userB);
|
|
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('jf-machine'),
|
|
ratingKey: 'show-1',
|
|
globalKey: 'profile-a|jf-machine:show-1',
|
|
targetType: 'show',
|
|
episodeCount: 1,
|
|
);
|
|
|
|
final executor = SyncRuleExecutor(database: db);
|
|
final results = await executor.executeSyncRules(
|
|
profileId: 'profile-b',
|
|
serverManager: manager,
|
|
downloads: const {},
|
|
metadata: const {},
|
|
associateDownload: (_, _) async {},
|
|
queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true,
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results, isEmpty);
|
|
expect(paths, isEmpty);
|
|
});
|
|
|
|
test('profile-scoped Jellyfin sync rule counts shared public downloads as already present', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
JellyfinApiCache.initialize(db);
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final paths = <String>[];
|
|
final userB = JellyfinClient.forTesting(
|
|
connection: _jellyfinConnection('user-b'),
|
|
httpClient: MockClient((request) async {
|
|
paths.add('${request.method} ${request.url.path}?${request.url.query}');
|
|
if (request.method == 'GET' && request.url.path == '/Users/user-b/Items/show-1') {
|
|
return http.Response(
|
|
'{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}',
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.method == 'GET' && request.url.path == '/Items') {
|
|
return http.Response(
|
|
'{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}',
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('not found', 404);
|
|
}),
|
|
);
|
|
addTearDown(userB.close);
|
|
manager.debugRegisterJellyfinClientForTesting(userB);
|
|
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-b',
|
|
serverId: ServerId('jf-machine'),
|
|
ratingKey: 'show-1',
|
|
globalKey: 'profile-b|jf-machine:show-1',
|
|
targetType: 'show',
|
|
episodeCount: 1,
|
|
);
|
|
|
|
final queued = <MediaItem>[];
|
|
final associated = <String>[];
|
|
final executor = SyncRuleExecutor(database: db);
|
|
final results = await executor.executeSyncRules(
|
|
profileId: 'profile-b',
|
|
serverManager: manager,
|
|
downloads: const {
|
|
'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
|
|
},
|
|
metadata: const {},
|
|
associateDownload: (_, globalKey) async => associated.add(globalKey),
|
|
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
|
queued.add(item);
|
|
return true;
|
|
},
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results, isEmpty);
|
|
expect(queued, isEmpty);
|
|
expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
|
|
expect(associated, ['jf-machine:ep-1']);
|
|
expect((await db.getSyncRule('profile-b|jf-machine:show-1'))!.downloadLinksInitialized, isTrue);
|
|
});
|
|
|
|
test('show sync rule respects includeSpecials=false when expanding episodes', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final client = _PlayableDescendantsClient([
|
|
_episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
|
|
_episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
|
|
_episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'),
|
|
]);
|
|
manager.debugRegisterClientForTesting(client);
|
|
|
|
const ruleKey = 'profile-a|plex-machine:show-1';
|
|
final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('plex-machine'),
|
|
ratingKey: 'show-1',
|
|
globalKey: ruleKey,
|
|
targetType: 'show',
|
|
episodeCount: 0,
|
|
includeSpecials: false,
|
|
);
|
|
|
|
final queued = <MediaItem>[];
|
|
final executor = SyncRuleExecutor(database: db);
|
|
final results = await executor.executeSyncRules(
|
|
profileId: 'profile-a',
|
|
serverManager: manager,
|
|
downloads: const {},
|
|
metadata: {ruleKey: show},
|
|
associateDownload: (_, _) async {},
|
|
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
|
queued.add(item);
|
|
return true;
|
|
},
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results.single.queuedCount, 2);
|
|
expect(queued.map((item) => item.id), ['s1e1', 's1e2']);
|
|
expect(client.fetchPlayableDescendantsCalls, ['show-1']);
|
|
});
|
|
|
|
test('Jellyfin playlist sync associates an already-downloaded member', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final client = _PlaylistPagingClient();
|
|
manager.debugRegisterClientForTesting(client);
|
|
const ruleKey = 'profile-a|jf-machine:playlist-1';
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('jf-machine'),
|
|
ratingKey: 'playlist-1',
|
|
globalKey: ruleKey,
|
|
targetType: 'playlist',
|
|
episodeCount: 0,
|
|
downloadFilter: SyncRuleFilter.all,
|
|
);
|
|
final associated = <String>[];
|
|
|
|
final results = await SyncRuleExecutor(database: db).executeSyncRules(
|
|
profileId: 'profile-a',
|
|
serverManager: manager,
|
|
downloads: const {
|
|
'jf-machine:episode-1': DownloadProgress(globalKey: 'jf-machine:episode-1', status: DownloadStatus.completed),
|
|
},
|
|
metadata: const {},
|
|
associateDownload: (_, globalKey) async => associated.add(globalKey),
|
|
queueSingleDownload: (_, _, {int mediaIndex = 0}) async {
|
|
fail('an already-downloaded playlist member must not be queued');
|
|
},
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results, isEmpty);
|
|
expect(associated, ['jf-machine:episode-1']);
|
|
expect(client.playlistPageCalls, [(start: 0, size: 200)]);
|
|
expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
|
|
});
|
|
|
|
test('collection sync rule pages through collection API instead of metadata children', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final client = _CollectionPagingClient();
|
|
manager.debugRegisterClientForTesting(client);
|
|
|
|
const ruleKey = 'profile-a|plex-machine:collection-1';
|
|
final collection = testMediaItem(
|
|
id: 'collection-1',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.collection,
|
|
title: 'Collection',
|
|
serverId: 'plex-machine',
|
|
);
|
|
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('plex-machine'),
|
|
ratingKey: 'collection-1',
|
|
globalKey: ruleKey,
|
|
targetType: 'collection',
|
|
episodeCount: 0,
|
|
downloadFilter: SyncRuleFilter.all,
|
|
);
|
|
|
|
final queued = <MediaItem>[];
|
|
final associated = <String>[];
|
|
final executor = SyncRuleExecutor(database: db);
|
|
final results = await executor.executeSyncRules(
|
|
profileId: 'profile-a',
|
|
serverManager: manager,
|
|
downloads: const {},
|
|
metadata: {ruleKey: collection},
|
|
associateDownload: (_, globalKey) async => associated.add(globalKey),
|
|
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
|
queued.add(item);
|
|
return true;
|
|
},
|
|
isOffline: false,
|
|
force: true,
|
|
);
|
|
|
|
expect(results.single.queuedCount, 1);
|
|
expect(queued.single.id, 'movie-1');
|
|
expect(associated, ['plex-machine:movie-1']);
|
|
expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
|
|
expect(client.collectionPageCalls, [(start: 0, size: 100)]);
|
|
expect(client.fetchChildrenCalled, isFalse);
|
|
});
|
|
|
|
test('legacy list backfill associates active members without queueing', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
final manager = MultiServerManager();
|
|
addTearDown(() async {
|
|
manager.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
final client = _CollectionPagingClient();
|
|
manager.debugRegisterClientForTesting(client);
|
|
const ruleKey = 'profile-a|plex-machine:collection-1';
|
|
final collection = testMediaItem(
|
|
id: 'collection-1',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.collection,
|
|
title: 'Collection',
|
|
serverId: 'plex-machine',
|
|
);
|
|
await db.insertSyncRule(
|
|
profileId: 'profile-a',
|
|
serverId: ServerId('plex-machine'),
|
|
ratingKey: 'collection-1',
|
|
globalKey: ruleKey,
|
|
targetType: 'collection',
|
|
episodeCount: 0,
|
|
downloadFilter: SyncRuleFilter.all,
|
|
);
|
|
final rule = (await db.getSyncRule(ruleKey))!;
|
|
final associated = <String>[];
|
|
|
|
final backfilled = await SyncRuleExecutor(database: db).backfillListRuleDownloadLinks(
|
|
rule: rule,
|
|
serverManager: manager,
|
|
downloads: const {
|
|
'plex-machine:movie-1': DownloadProgress(globalKey: 'plex-machine:movie-1', status: DownloadStatus.completed),
|
|
},
|
|
metadata: {ruleKey: collection},
|
|
associateDownload: (_, globalKey) async => associated.add(globalKey),
|
|
);
|
|
|
|
expect(backfilled, isTrue);
|
|
expect(associated, ['plex-machine:movie-1']);
|
|
expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
|
|
});
|
|
|
|
test('collectItemsForList accepts tracks and expands albums/artists', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
final executor = SyncRuleExecutor(database: db);
|
|
|
|
final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)];
|
|
final client = _PlayableDescendantsClient(albumTracks);
|
|
|
|
final items = [
|
|
_track('loose-track'),
|
|
testMediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'),
|
|
testMediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'),
|
|
// Still skipped: nested lists / unplayable kinds.
|
|
testMediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'),
|
|
];
|
|
|
|
final out = <MediaItem>[];
|
|
await executor.collectItemsForList(client, items, unwatchedOnly: false, out: out);
|
|
|
|
expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']);
|
|
expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']);
|
|
|
|
// unwatchedOnly applies the play-count filter to tracks too.
|
|
final unwatched = <MediaItem>[];
|
|
await executor.collectItemsForList(
|
|
client,
|
|
[_track('played-track', played: true), items[1]],
|
|
unwatchedOnly: true,
|
|
out: unwatched,
|
|
);
|
|
expect(unwatched.map((i) => i.id), ['album-track-1']);
|
|
});
|
|
}
|
|
|
|
MediaItem _track(String id, {bool played = false}) {
|
|
return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0);
|
|
}
|
|
|
|
MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) {
|
|
return testMediaItem(
|
|
id: id,
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: id,
|
|
parentIndex: parentIndex,
|
|
index: index,
|
|
originallyAvailableAt: originallyAvailableAt,
|
|
);
|
|
}
|
|
|
|
class _PlayableDescendantsClient implements MediaServerClient {
|
|
_PlayableDescendantsClient(this.leaves);
|
|
|
|
final List<MediaItem> leaves;
|
|
final fetchPlayableDescendantsCalls = <String>[];
|
|
|
|
@override
|
|
ServerId get serverId => ServerId('plex-machine');
|
|
|
|
@override
|
|
String? get serverName => 'Plex';
|
|
|
|
@override
|
|
MediaBackend get backend => MediaBackend.plex;
|
|
|
|
@override
|
|
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
|
|
|
@override
|
|
bool get isOfflineMode => false;
|
|
|
|
@override
|
|
void close() {}
|
|
|
|
@override
|
|
Future<MediaItem?> fetchItem(String id) async => null;
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
|
fetchPlayableDescendantsCalls.add(parentId);
|
|
return leaves;
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _PlaylistPagingClient implements MediaServerClient {
|
|
final playlistPageCalls = <({int? start, int? size})>[];
|
|
|
|
@override
|
|
ServerId get serverId => ServerId('jf-machine');
|
|
|
|
@override
|
|
String? get serverName => 'Jellyfin';
|
|
|
|
@override
|
|
MediaBackend get backend => MediaBackend.jellyfin;
|
|
|
|
@override
|
|
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
|
|
|
|
@override
|
|
bool get isOfflineMode => false;
|
|
|
|
@override
|
|
void close() {}
|
|
|
|
@override
|
|
Future<MediaItem?> fetchItem(String id) async => null;
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, abort}) async {
|
|
playlistPageCalls.add((start: start, size: size));
|
|
expect(id, 'playlist-1');
|
|
return LibraryPage(
|
|
items: [
|
|
testMediaItem(id: 'episode-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, title: 'Episode'),
|
|
],
|
|
totalCount: 1,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _CollectionPagingClient implements MediaServerClient {
|
|
bool fetchChildrenCalled = false;
|
|
final collectionPageCalls = <({int? start, int? size})>[];
|
|
|
|
@override
|
|
ServerId get serverId => ServerId('plex-machine');
|
|
|
|
@override
|
|
String? get serverName => 'Plex';
|
|
|
|
@override
|
|
MediaBackend get backend => MediaBackend.plex;
|
|
|
|
@override
|
|
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
|
|
|
@override
|
|
bool get isOfflineMode => false;
|
|
|
|
@override
|
|
void close() {}
|
|
|
|
@override
|
|
Future<MediaItem?> fetchItem(String id) async => null;
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchChildren(String parentId) async {
|
|
fetchChildrenCalled = true;
|
|
throw StateError('collection rules must not use fetchChildren');
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
|
String collectionId, {
|
|
int? start,
|
|
int? size,
|
|
abort,
|
|
String? libraryId,
|
|
String? libraryTitle,
|
|
}) async {
|
|
collectionPageCalls.add((start: start, size: size));
|
|
expect(collectionId, 'collection-1');
|
|
return LibraryPage(
|
|
items: [testMediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')],
|
|
totalCount: 1,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|