Files
plezy/test/services/sync_rule_executor_test.dart
T
edde746 764345f021 feat(music): audio downloads and music home rows
Album/artist downloads expand to tracks with pinned parent metadata,
aggregate progress, container deletes with reference-counted album
covers, and a Music tab on the downloads screen with fully offline
album playback. Home rows include music libraries (fixes plex hub
items being filtered to video types) and audio playlists join
download/sync rules.
2026-07-05 21:51:37 +02:00

539 lines
17 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/prefs.dart';
JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection(
id: 'jf-machine/$userId',
baseUrl: 'https://jf.example.com',
serverName: 'Shared JF',
serverMachineId: 'jf-machine',
userId: userId,
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 {},
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 {},
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 {},
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 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 {},
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('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 = MediaItem(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},
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('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 = MediaItem(
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 executor = SyncRuleExecutor(database: db);
final results = await executor.executeSyncRules(
profileId: 'profile-a',
serverManager: manager,
downloads: const {},
metadata: {ruleKey: collection},
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(client.collectionPageCalls, [(start: 0, size: 100)]);
expect(client.fetchChildrenCalled, isFalse);
});
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'),
MediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'),
MediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'),
// Still skipped: nested lists / unplayable kinds.
MediaItem(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 MediaItem(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 MediaItem(
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 _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: [MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')],
totalCount: 1,
offset: start ?? 0,
);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}