1264 lines
47 KiB
Dart
1264 lines
47 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'package:plezy/media/ids.dart';
|
||
|
||
import 'package:drift/native.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/exceptions/media_server_exceptions.dart';
|
||
import 'package:plezy/media/media_backend.dart';
|
||
import 'package:plezy/media/media_kind.dart';
|
||
import 'package:plezy/media/media_library.dart';
|
||
import 'package:plezy/media/media_hub.dart';
|
||
import 'package:plezy/media/server_capabilities.dart';
|
||
import 'package:plezy/media/media_server_client.dart';
|
||
import 'package:plezy/media/media_item.dart';
|
||
import 'package:plezy/models/plex/plex_config.dart';
|
||
import 'package:plezy/services/data_aggregation_service.dart';
|
||
import 'package:plezy/services/jellyfin_client.dart';
|
||
import 'package:plezy/services/multi_server_manager.dart';
|
||
import 'package:plezy/services/plex_api_cache.dart';
|
||
import 'package:plezy/services/settings_service.dart';
|
||
import 'package:plezy/utils/media_server_http_client.dart';
|
||
|
||
import '../test_helpers/backend_client_fixtures.dart';
|
||
import '../test_helpers/media_items.dart';
|
||
import '../test_helpers/prefs.dart';
|
||
|
||
JellyfinConnection _conn() => testJellyfinConnection(
|
||
userName: 'edde',
|
||
accessToken: 'tok-abc',
|
||
deviceId: 'dev-xyz',
|
||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||
);
|
||
|
||
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
|
||
|
||
/// Minimal client with independently configurable library and search outcomes.
|
||
class _LibrariesClient implements MediaServerClient {
|
||
_LibrariesClient(
|
||
this.serverId, {
|
||
this.error,
|
||
this.libraries = const [],
|
||
this.searchError,
|
||
this.searchResults = const [],
|
||
});
|
||
|
||
@override
|
||
final ServerId serverId;
|
||
|
||
@override
|
||
final String serverName = 'Server';
|
||
|
||
final Object? error;
|
||
final List<MediaLibrary> libraries;
|
||
final Object? searchError;
|
||
final List<MediaItem> searchResults;
|
||
Set<String>? lastExcludedLibraryIds;
|
||
|
||
@override
|
||
Future<List<MediaLibrary>> fetchLibraries() async {
|
||
if (error != null) throw error!;
|
||
return libraries;
|
||
}
|
||
|
||
@override
|
||
Future<List<MediaItem>> searchItems(
|
||
String query, {
|
||
int limit = 100,
|
||
AbortController? abort,
|
||
Set<String> excludedLibraryIds = const {},
|
||
}) async {
|
||
lastExcludedLibraryIds = excludedLibraryIds;
|
||
abort?.throwIfAborted();
|
||
if (searchError != null) throw searchError!;
|
||
return searchResults;
|
||
}
|
||
|
||
@override
|
||
void close() {}
|
||
|
||
@override
|
||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||
}
|
||
|
||
/// Per-library hub client whose fetches are held open individually, so a test
|
||
/// can observe exactly when the fan-out starts each library.
|
||
class _GatedHubsClient implements MediaServerClient {
|
||
_GatedHubsClient(this.libraries);
|
||
|
||
final List<MediaLibrary> libraries;
|
||
final started = <String>[];
|
||
final _gates = <String, Completer<List<MediaHub>>>{};
|
||
|
||
void complete(String libraryId) {
|
||
_gates[libraryId]!.complete([
|
||
MediaHub(
|
||
id: '$libraryId.recent',
|
||
title: libraryId,
|
||
type: 'mixed',
|
||
items: const [],
|
||
size: 0,
|
||
libraryId: libraryId,
|
||
),
|
||
]);
|
||
}
|
||
|
||
@override
|
||
ServerId get serverId => ServerId('server-1');
|
||
|
||
@override
|
||
String get serverName => 'Server';
|
||
|
||
@override
|
||
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
|
||
|
||
@override
|
||
Future<List<MediaLibrary>> fetchLibraries() async => libraries;
|
||
|
||
@override
|
||
Future<List<MediaHub>> fetchLibraryHubs(
|
||
String libraryId, {
|
||
String? libraryName,
|
||
int limit = defaultHubPreviewLimit,
|
||
bool includePlaybackHubs = true,
|
||
MediaKind? libraryKind,
|
||
HubFetchDiagnostics? diagnostics,
|
||
}) {
|
||
started.add(libraryId);
|
||
return (_gates[libraryId] = Completer<List<MediaHub>>()).future;
|
||
}
|
||
|
||
@override
|
||
void close() {}
|
||
|
||
@override
|
||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||
}
|
||
|
||
/// Covers the remaining cross-server [DataAggregationService] fan-out surface;
|
||
/// single-server calls now go through the per-server client context.
|
||
void main() {
|
||
late AppDatabase db;
|
||
late MultiServerManager manager;
|
||
late DataAggregationService service;
|
||
|
||
setUp(() {
|
||
resetSharedPreferencesForTest();
|
||
SettingsService.resetForTesting();
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
PlexApiCache.initialize(db);
|
||
manager = MultiServerManager();
|
||
service = DataAggregationService(manager);
|
||
});
|
||
|
||
tearDown(() async {
|
||
manager.dispose();
|
||
await db.close();
|
||
});
|
||
|
||
group('DataAggregationService cross-server aggregation', () {
|
||
test('getMediaLibrariesFromAllServers returns empty when no clients connected', () async {
|
||
final result = await service.getMediaLibrariesFromAllServers();
|
||
expect(result.libraries, isEmpty);
|
||
expect(result.succeededServerIds, isEmpty);
|
||
});
|
||
test('per-library hub fan-out refills a free slot instead of waiting for the batch', () async {
|
||
// Old shape: batches of three separated by `Future.wait`, so the 4th
|
||
// library could not start until ALL of the first three had answered and
|
||
// one slow row stalled every row queued behind it (#1784). A sliding
|
||
// window keeps the same peak concurrency with no head-of-line blocking.
|
||
final client = _GatedHubsClient([
|
||
for (var i = 1; i <= 4; i++)
|
||
MediaLibrary(
|
||
id: 'lib-$i',
|
||
backend: MediaBackend.jellyfin,
|
||
title: 'Library $i',
|
||
kind: MediaKind.movie,
|
||
serverId: ServerId('server-1'),
|
||
),
|
||
]);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final pending = service.getHubsFromAllServers(useGlobalHubs: false, includePlaybackHubs: false);
|
||
await pumpEventQueue();
|
||
|
||
expect(client.started, ['lib-1', 'lib-2', 'lib-3'], reason: 'concurrency stays at 3');
|
||
|
||
// Free exactly one slot. The 4th library must take it immediately, while
|
||
// its two batch-mates are still in flight.
|
||
client.complete('lib-1');
|
||
await pumpEventQueue();
|
||
|
||
expect(client.started, ['lib-1', 'lib-2', 'lib-3', 'lib-4']);
|
||
|
||
// Finish out of order — hub order must still follow library order.
|
||
client.complete('lib-4');
|
||
client.complete('lib-3');
|
||
client.complete('lib-2');
|
||
|
||
final result = await pending;
|
||
expect(result.hubs.map((hub) => hub.libraryId), ['lib-1', 'lib-2', 'lib-3', 'lib-4']);
|
||
});
|
||
|
||
test('searchAcrossServers and getOnDeckFromAllServers return empty when no clients', () async {
|
||
final search = await service.searchAcrossServers('hello');
|
||
expect(search.items, isEmpty);
|
||
expect(search.succeededServerIds, isEmpty);
|
||
expect(search.cancelledServerIds, isEmpty);
|
||
expect(search.failedServerIds, isEmpty);
|
||
final onDeck = await service.getOnDeckFromAllServers();
|
||
expect(onDeck.items, isEmpty);
|
||
expect(onDeck.succeededServerIds, isEmpty);
|
||
});
|
||
|
||
test('classifies cancelled per-server failures apart from settled ones', () async {
|
||
// A cancelled fetch (our own client torn down mid-request) says nothing
|
||
// about the server's content; consumers use cancelledServerIds to keep
|
||
// a disrupted pass from being committed as authoritative. A settled
|
||
// failure (server down) lands in neither set.
|
||
manager.debugRegisterClientForTesting(
|
||
_LibrariesClient(
|
||
ServerId('ok'),
|
||
libraries: [MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies', serverId: ServerId('ok'))],
|
||
),
|
||
);
|
||
manager.debugRegisterClientForTesting(
|
||
_LibrariesClient(
|
||
ServerId('torn-down'),
|
||
error: MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'),
|
||
),
|
||
);
|
||
manager.debugRegisterClientForTesting(
|
||
_LibrariesClient(
|
||
ServerId('down'),
|
||
error: MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'refused'),
|
||
),
|
||
);
|
||
|
||
final result = await service.getMediaLibrariesFromAllServers();
|
||
|
||
expect(result.libraries.map((l) => l.title), ['Movies']);
|
||
expect(result.succeededServerIds, {'ok'});
|
||
expect(result.cancelledServerIds, {'torn-down'});
|
||
});
|
||
|
||
test('search classifies successful, cancelled, and failed servers independently', () async {
|
||
final item = testMediaItem(
|
||
id: 'show-1',
|
||
backend: MediaBackend.plex,
|
||
kind: MediaKind.show,
|
||
title: 'Target',
|
||
serverId: 'ok',
|
||
);
|
||
manager.debugRegisterClientForTesting(_LibrariesClient(ServerId('ok'), searchResults: [item]));
|
||
manager.debugRegisterClientForTesting(
|
||
_LibrariesClient(
|
||
ServerId('cancelled'),
|
||
searchError: MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'superseded search'),
|
||
),
|
||
);
|
||
manager.debugRegisterClientForTesting(
|
||
_LibrariesClient(
|
||
ServerId('failed'),
|
||
searchError: MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'refused'),
|
||
),
|
||
);
|
||
|
||
final result = await service.searchAcrossServers('Target');
|
||
|
||
expect(result.items.map((item) => item.id), ['show-1']);
|
||
expect(result.succeededServerIds, {'ok'});
|
||
expect(result.cancelledServerIds, {'cancelled'});
|
||
expect(result.failedServerIds, {'failed'});
|
||
});
|
||
|
||
test('searchAcrossServers drops hidden-library results and keeps unattributed ones', () async {
|
||
final visible = testMediaItem(
|
||
id: 'visible-1',
|
||
backend: MediaBackend.plex,
|
||
kind: MediaKind.movie,
|
||
title: 'Target',
|
||
serverId: 'plex',
|
||
libraryId: '1',
|
||
);
|
||
final hidden = testMediaItem(
|
||
id: 'hidden-1',
|
||
backend: MediaBackend.plex,
|
||
kind: MediaKind.movie,
|
||
title: 'Target',
|
||
serverId: 'plex',
|
||
libraryId: '2',
|
||
);
|
||
// Plex search asks for external media, and shared rows carry no local
|
||
// section. The user cannot have hidden a library that isn't theirs, so
|
||
// an unattributed row must survive the filter.
|
||
final unattributed = testMediaItem(
|
||
id: 'shared-1',
|
||
backend: MediaBackend.plex,
|
||
kind: MediaKind.movie,
|
||
title: 'Target',
|
||
serverId: 'plex',
|
||
);
|
||
manager.debugRegisterClientForTesting(
|
||
_LibrariesClient(ServerId('plex'), searchResults: [visible, hidden, unattributed]),
|
||
);
|
||
|
||
final result = await service.searchAcrossServers('Target', hiddenLibraryKeys: {'plex:2'});
|
||
|
||
expect(result.items.map((item) => item.id), unorderedEquals(['visible-1', 'shared-1']));
|
||
expect(result.succeededServerIds, {'plex'});
|
||
});
|
||
|
||
test('hidden-library results are dropped before the ranking limit is spent', () async {
|
||
// The hidden row is the exact-title match, so it outranks the visible
|
||
// one. Filtering after ranking would let it consume the single slot and
|
||
// return nothing at all.
|
||
final hidden = testMediaItem(
|
||
id: 'hidden-1',
|
||
backend: MediaBackend.plex,
|
||
kind: MediaKind.movie,
|
||
title: 'Target',
|
||
serverId: 'plex',
|
||
libraryId: '2',
|
||
);
|
||
final visible = testMediaItem(
|
||
id: 'visible-1',
|
||
backend: MediaBackend.plex,
|
||
kind: MediaKind.movie,
|
||
title: 'Target Sequel',
|
||
serverId: 'plex',
|
||
libraryId: '1',
|
||
);
|
||
manager.debugRegisterClientForTesting(_LibrariesClient(ServerId('plex'), searchResults: [hidden, visible]));
|
||
|
||
final result = await service.searchAcrossServers('Target', limit: 1, hiddenLibraryKeys: {'plex:2'});
|
||
|
||
expect(result.items.map((item) => item.id), ['visible-1']);
|
||
});
|
||
|
||
test('each server is told only about its own hidden libraries', () async {
|
||
// Global keys are cross-server; a backend that scopes its search needs
|
||
// the bare library ids it actually owns, and none of its neighbour's.
|
||
final alpha = _LibrariesClient(ServerId('alpha'));
|
||
final beta = _LibrariesClient(ServerId('beta'));
|
||
manager.debugRegisterClientForTesting(alpha);
|
||
manager.debugRegisterClientForTesting(beta);
|
||
|
||
await service.searchAcrossServers('Target', hiddenLibraryKeys: {'alpha:1', 'alpha:9', 'beta:1'});
|
||
|
||
expect(alpha.lastExcludedLibraryIds, {'1', '9'});
|
||
expect(beta.lastExcludedLibraryIds, {'1'});
|
||
});
|
||
|
||
test('no hidden libraries passes an empty exclusion set', () async {
|
||
final client = _LibrariesClient(ServerId('alpha'));
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
await service.searchAcrossServers('Target');
|
||
|
||
expect(client.lastExcludedLibraryIds, isEmpty);
|
||
});
|
||
|
||
test('searchAcrossServers overfetches and ranks before trimming across backends', () async {
|
||
final plexRequests = <Uri>[];
|
||
final jellyfinRequests = <Uri>[];
|
||
|
||
final plexClient = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
plexRequests.add(req.url);
|
||
if (req.url.path == '/library/search') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'SearchResult': [
|
||
{
|
||
'score': 100,
|
||
'Metadata': {'ratingKey': 'plex-movie', 'type': 'movie', 'title': 'Spider Man Returns'},
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(plexClient.close);
|
||
manager.debugRegisterClientForTesting(plexClient);
|
||
|
||
final jellyfinClient = JellyfinClient.forTesting(
|
||
connection: _conn(),
|
||
httpClient: MockClient((req) async {
|
||
jellyfinRequests.add(req.url);
|
||
if (req.url.path == '/Items') {
|
||
return _json({
|
||
'Items': [
|
||
{'Id': 'jf-show', 'Type': 'Series', 'Name': 'Spider‑Man'},
|
||
],
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(jellyfinClient.close);
|
||
manager.debugRegisterJellyfinClientForTesting(jellyfinClient);
|
||
|
||
final results = await service.searchAcrossServers('Spider Man', limit: 1);
|
||
|
||
expect(results.items.map((item) => item.id), ['jf-show']);
|
||
expect(plexRequests.single.queryParameters['query'], 'Spider Man');
|
||
expect(plexRequests.single.queryParameters['limit'], '100');
|
||
expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv,music');
|
||
// Jellyfin search fans out to /Items plus a best-effort /Artists call
|
||
// (500 above → treated as empty).
|
||
final jfItemsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Items');
|
||
expect(jfItemsRequest.queryParameters['Limit'], '100');
|
||
expect(jfItemsRequest.queryParameters['SearchTerm'], 'Spider Man');
|
||
final jfArtistsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Artists');
|
||
expect(jfArtistsRequest.queryParameters['searchTerm'], 'Spider Man');
|
||
});
|
||
|
||
test('getOnDeckFromAllServers forwards preview limit to clients', () async {
|
||
final captured = <Uri>[];
|
||
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/hubs') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/continueWatching',
|
||
'title': 'Continue Watching',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.continue',
|
||
'size': 1,
|
||
'Metadata': [
|
||
{'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie 1'},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final result = await service.getOnDeckFromAllServers(limit: 21);
|
||
|
||
expect(result.items.map((item) => item.id), ['movie-1']);
|
||
expect(result.succeededServerIds, {'plex-1'});
|
||
expect(captured.single.path, '/hubs');
|
||
expect(captured.single.queryParameters['count'], '21');
|
||
});
|
||
|
||
test('getOnDeckFromAllServers filters hidden Plex continue-watching libraries', () async {
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
if (req.url.path == '/hubs') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/continueWatching',
|
||
'title': 'Continue Watching',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.continue',
|
||
'size': 2,
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': 'movie-visible',
|
||
'type': 'movie',
|
||
'title': 'Visible Movie',
|
||
'lastViewedAt': 100,
|
||
'librarySectionID': 1,
|
||
},
|
||
{
|
||
'ratingKey': 'movie-hidden',
|
||
'type': 'movie',
|
||
'title': 'Hidden Movie',
|
||
'lastViewedAt': 200,
|
||
'librarySectionID': 2,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final result = await service.getOnDeckFromAllServers(limit: 10, hiddenLibraryKeys: {'plex-1:2'});
|
||
|
||
expect(result.items.map((item) => item.id), ['movie-visible']);
|
||
expect(result.succeededServerIds, {'plex-1'});
|
||
});
|
||
|
||
test('getOnDeckFromAllServers hides duplicate show entries by stable show ids', () async {
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
if (req.url.path == '/hubs') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/continueWatching',
|
||
'title': 'Continue Watching',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.continue',
|
||
'size': 2,
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': 'old-episode',
|
||
'type': 'episode',
|
||
'title': 'Episode 1',
|
||
'grandparentRatingKey': 'old-show',
|
||
'grandparentTitle': 'Shared Show',
|
||
'guid': 'plex://episode/shared-episode-1',
|
||
'lastViewedAt': 100,
|
||
'librarySectionID': 1,
|
||
},
|
||
{
|
||
'ratingKey': 'new-episode',
|
||
'type': 'episode',
|
||
'title': 'Episode 2',
|
||
'grandparentRatingKey': 'new-show',
|
||
'grandparentTitle': 'Shared Show',
|
||
'guid': 'plex://episode/shared-episode-2',
|
||
'lastViewedAt': 200,
|
||
'librarySectionID': 2,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/library/metadata/old-show' || req.url.path == '/library/metadata/new-show') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': req.url.pathSegments.last,
|
||
'type': 'show',
|
||
'title': 'Shared Show',
|
||
'Guid': [
|
||
{'id': 'tvdb://12345'},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final result = await service.getOnDeckFromAllServers(limit: 10);
|
||
|
||
expect(result.items.map((item) => item.id), ['new-episode']);
|
||
expect(result.succeededServerIds, {'plex-1'});
|
||
});
|
||
|
||
test('getOnDeckFromAllServers prefers the locally last-played duplicate sibling', () async {
|
||
// Two libraries carry the same show (1080p + 4K, matched by tvdb id);
|
||
// the server syncs watch state so lastViewedAt favours neither
|
||
// reliably. The locally recorded play must decide the surviving card —
|
||
// and it must keep the winner in the group's original shelf slot,
|
||
// ahead of the unrelated movie sorted between the two episodes (#1492).
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
if (req.url.path == '/hubs') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/continueWatching',
|
||
'title': 'Continue Watching',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.continue',
|
||
'size': 3,
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': 'hd-episode',
|
||
'type': 'episode',
|
||
'title': 'Episode 1',
|
||
'grandparentRatingKey': 'hd-show',
|
||
'grandparentTitle': 'Shared Show',
|
||
'guid': 'plex://episode/shared-episode-hd',
|
||
'lastViewedAt': 200,
|
||
'librarySectionID': 1,
|
||
},
|
||
{'ratingKey': 'movie-between', 'type': 'movie', 'title': 'Unrelated Movie', 'lastViewedAt': 150},
|
||
{
|
||
'ratingKey': 'uhd-episode',
|
||
'type': 'episode',
|
||
'title': 'Episode 1',
|
||
'grandparentRatingKey': 'uhd-show',
|
||
'grandparentTitle': 'Shared Show',
|
||
'guid': 'plex://episode/shared-episode-uhd',
|
||
'lastViewedAt': 100,
|
||
'librarySectionID': 2,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/library/metadata/hd-show' || req.url.path == '/library/metadata/uhd-show') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': req.url.pathSegments.last,
|
||
'type': 'show',
|
||
'title': 'Shared Show',
|
||
'Guid': [
|
||
{'id': 'tvdb://12345'},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
// The user last played something in the 4K library's show tree.
|
||
final settings = await SettingsService.getInstance();
|
||
await settings.write(SettingsService.localLastPlayedAt, {'plex-1:uhd-show': 999999});
|
||
|
||
final result = await service.getOnDeckFromAllServers(limit: 10);
|
||
|
||
// uhd-episode wins the duplicate group and takes the group's slot
|
||
// (before the movie); without local history hd-episode (newest
|
||
// lastViewedAt) would have survived.
|
||
expect(result.items.map((item) => item.id), ['uhd-episode', 'movie-between']);
|
||
});
|
||
|
||
test('getOnDeckFromAllServers prefers a duplicate recorded by item key', () async {
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
if (req.url.path == '/hubs') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/continueWatching',
|
||
'title': 'Continue Watching',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.continue',
|
||
'size': 2,
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': 'movie-hd',
|
||
'type': 'movie',
|
||
'title': 'Shared Movie',
|
||
'guid': 'plex://movie/shared-movie',
|
||
'lastViewedAt': 200,
|
||
'librarySectionID': 1,
|
||
},
|
||
{
|
||
'ratingKey': 'movie-uhd',
|
||
'type': 'movie',
|
||
'title': 'Shared Movie',
|
||
'guid': 'plex://movie/shared-movie',
|
||
'lastViewedAt': 100,
|
||
'librarySectionID': 2,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/library/metadata/movie-hd' || req.url.path == '/library/metadata/movie-uhd') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': req.url.pathSegments.last,
|
||
'type': 'movie',
|
||
'title': 'Shared Movie',
|
||
'Guid': [
|
||
{'id': 'tmdb://777'},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final settings = await SettingsService.getInstance();
|
||
await settings.write(SettingsService.localLastPlayedAt, {'plex-1:movie-uhd': 999999});
|
||
|
||
final result = await service.getOnDeckFromAllServers(limit: 10);
|
||
|
||
expect(result.items.map((item) => item.id), ['movie-uhd']);
|
||
});
|
||
|
||
test('getOnDeckFromAllServers keeps duplicate titles without stable ids', () async {
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
httpClient: MockClient((req) async {
|
||
if (req.url.path == '/hubs') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/continueWatching',
|
||
'title': 'Continue Watching',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.continue',
|
||
'size': 2,
|
||
'Metadata': [
|
||
{
|
||
'ratingKey': 'old-unmatched',
|
||
'type': 'episode',
|
||
'title': 'Episode 1',
|
||
'grandparentRatingKey': 'old-unmatched-show',
|
||
'grandparentTitle': 'Shared Show',
|
||
'guid': 'com.plexapp.agents.none://old-unmatched',
|
||
'lastViewedAt': 100,
|
||
},
|
||
{
|
||
'ratingKey': 'new-unmatched',
|
||
'type': 'episode',
|
||
'title': 'Episode 2',
|
||
'grandparentRatingKey': 'new-unmatched-show',
|
||
'grandparentTitle': 'Shared Show',
|
||
'guid': 'com.plexapp.agents.none://new-unmatched',
|
||
'lastViewedAt': 200,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/library/metadata/old-unmatched-show' ||
|
||
req.url.path == '/library/metadata/new-unmatched-show') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Metadata': [
|
||
{'ratingKey': req.url.pathSegments.last, 'type': 'show', 'title': 'Shared Show'},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final result = await service.getOnDeckFromAllServers(limit: 10);
|
||
|
||
expect(result.items.map((item) => item.id), ['new-unmatched', 'old-unmatched']);
|
||
expect(result.succeededServerIds, {'plex-1'});
|
||
});
|
||
|
||
test('per-library hubs skip playback rows and fetch in bounded batches', () async {
|
||
final captured = <Uri>[];
|
||
var activeLatest = 0;
|
||
var maxActiveLatest = 0;
|
||
|
||
final client = JellyfinClient.forTesting(
|
||
connection: _conn(),
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/Users/user-1/Views') {
|
||
return _json({
|
||
'Items': [
|
||
{'Id': 'lib-1', 'Name': 'Lib 1', 'CollectionType': 'movies'},
|
||
{'Id': 'lib-2', 'Name': 'Lib 2', 'CollectionType': 'movies'},
|
||
{'Id': 'lib-3', 'Name': 'Lib 3', 'CollectionType': 'tvshows'},
|
||
{'Id': 'lib-4', 'Name': 'Lib 4', 'CollectionType': 'tvshows'},
|
||
],
|
||
});
|
||
}
|
||
if (req.url.path == '/Users/user-1/Items/Latest') {
|
||
activeLatest++;
|
||
if (activeLatest > maxActiveLatest) maxActiveLatest = activeLatest;
|
||
try {
|
||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||
final parentId = req.url.queryParameters['ParentId']!;
|
||
return _json({
|
||
'Items': [
|
||
{'Id': 'item-$parentId', 'Type': 'Movie', 'Name': 'Latest $parentId', 'ParentLibraryId': parentId},
|
||
],
|
||
});
|
||
} finally {
|
||
activeLatest--;
|
||
}
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterJellyfinClientForTesting(client);
|
||
|
||
final result = await service.getHubsFromAllServers(useGlobalHubs: false, includePlaybackHubs: false);
|
||
final hubs = result.hubs;
|
||
|
||
expect(result.succeededServerIds, {'srv-1'});
|
||
expect(hubs.map((h) => h.identifier), [
|
||
'library.lib-1.recent',
|
||
'library.lib-2.recent',
|
||
'library.lib-3.recent',
|
||
'library.lib-4.recent',
|
||
]);
|
||
expect(hubs.map((h) => h.items.single.id), ['item-lib-1', 'item-lib-2', 'item-lib-3', 'item-lib-4']);
|
||
expect(maxActiveLatest, lessThanOrEqualTo(3));
|
||
expect(captured.where((uri) => uri.path == '/UserItems/Resume' || uri.path == '/Shows/NextUp'), isEmpty);
|
||
expect(
|
||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
|
||
['lib-1', 'lib-2', 'lib-3', 'lib-4'],
|
||
);
|
||
expect(
|
||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['Limit']),
|
||
everyElement(defaultHubPreviewLimit.toString()),
|
||
);
|
||
});
|
||
|
||
test('global home layout falls back to per-library hubs for Jellyfin', () async {
|
||
final captured = <Uri>[];
|
||
|
||
final client = JellyfinClient.forTesting(
|
||
connection: _conn(),
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/Users/user-1/Views') {
|
||
return _json({
|
||
'Items': [
|
||
{'Id': 'movies', 'Name': 'Movies', 'CollectionType': 'movies'},
|
||
{'Id': 'shows', 'Name': 'Shows', 'CollectionType': 'tvshows'},
|
||
],
|
||
});
|
||
}
|
||
if (req.url.path == '/Users/user-1/Items/Latest') {
|
||
final parentId = req.url.queryParameters['ParentId'];
|
||
return switch (parentId) {
|
||
'movies' => _json({
|
||
'Items': [
|
||
{'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Latest Movie', 'ParentLibraryId': 'movies'},
|
||
],
|
||
}),
|
||
'shows' => _json({
|
||
'Items': [
|
||
{'Id': 'show-1', 'Type': 'Series', 'Name': 'Latest Show', 'ParentLibraryId': 'shows'},
|
||
],
|
||
}),
|
||
_ => http.Response('mixed latest should not be requested', 500),
|
||
};
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterJellyfinClientForTesting(client);
|
||
|
||
final result = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false);
|
||
final hubs = result.hubs;
|
||
|
||
expect(result.succeededServerIds, {'srv-1'});
|
||
expect(hubs.map((h) => h.identifier), ['library.movies.recent', 'library.shows.recent']);
|
||
expect(hubs.map((h) => h.items.single.id), ['movie-1', 'show-1']);
|
||
expect(captured.where((uri) => uri.path == '/Users/user-1/Views'), hasLength(1));
|
||
expect(
|
||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
|
||
['movies', 'shows'],
|
||
);
|
||
expect(
|
||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['Limit']),
|
||
everyElement(defaultHubPreviewLimit.toString()),
|
||
);
|
||
});
|
||
|
||
test('per-library home rows include clip and music libraries and skip photo (#1476)', () async {
|
||
final captured = <Uri>[];
|
||
|
||
final client = JellyfinClient.forTesting(
|
||
connection: _conn(),
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/Users/user-1/Views') {
|
||
return _json({
|
||
'Items': [
|
||
{'Id': 'movies', 'Name': 'Movies', 'CollectionType': 'movies'},
|
||
{'Id': 'mv', 'Name': 'Music Videos', 'CollectionType': 'musicvideos'},
|
||
{'Id': 'home-vids', 'Name': 'Home Videos', 'CollectionType': 'homevideos'},
|
||
{'Id': 'music', 'Name': 'Music', 'CollectionType': 'music'},
|
||
{'Id': 'photos', 'Name': 'Photos', 'CollectionType': 'photos'},
|
||
],
|
||
});
|
||
}
|
||
if (req.url.path == '/Users/user-1/Items/Latest') {
|
||
final parentId = req.url.queryParameters['ParentId'];
|
||
return switch (parentId) {
|
||
'movies' => _json({
|
||
'Items': [
|
||
{'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Latest Movie', 'ParentLibraryId': 'movies'},
|
||
],
|
||
}),
|
||
'mv' => _json({
|
||
'Items': [
|
||
{'Id': 'mv-1', 'Type': 'MusicVideo', 'Name': 'Latest Music Video', 'ParentLibraryId': 'mv'},
|
||
],
|
||
}),
|
||
'home-vids' => _json({
|
||
'Items': [
|
||
{'Id': 'vid-1', 'Type': 'Video', 'Name': 'Latest Home Video', 'ParentLibraryId': 'home-vids'},
|
||
],
|
||
}),
|
||
'music' => _json({
|
||
'Items': [
|
||
{'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Latest Album', 'ParentLibraryId': 'music'},
|
||
],
|
||
}),
|
||
_ => http.Response('latest should not be requested for $parentId', 500),
|
||
};
|
||
}
|
||
// Music library's played-track rows — empty so only the Latest
|
||
// Albums hub survives.
|
||
if (req.url.path == '/Items' && req.url.queryParameters['Filters'] == 'IsPlayed') {
|
||
return _json({'Items': const <Object>[]});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterJellyfinClientForTesting(client);
|
||
|
||
final result = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false);
|
||
final hubs = result.hubs;
|
||
|
||
expect(result.succeededServerIds, {'srv-1'});
|
||
expect(hubs.map((h) => h.identifier), [
|
||
'library.movies.recent',
|
||
'library.mv.recent',
|
||
'library.home-vids.recent',
|
||
'library.music.latestalbums',
|
||
]);
|
||
expect(hubs[1].items.single.kind, MediaKind.clip);
|
||
expect(hubs[3].items.single.kind, MediaKind.album);
|
||
expect(
|
||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
|
||
['movies', 'mv', 'home-vids', 'music'],
|
||
);
|
||
expect(
|
||
captured.where((uri) => uri.path == '/Items' && uri.queryParameters['Filters'] == 'IsPlayed'),
|
||
isEmpty,
|
||
reason: 'the home screen excludes playback-derived music rows',
|
||
);
|
||
// Music Latest returns album FOLDER dtos — count/user-data fields would
|
||
// each cost a recursive per-album COUNT query (#1552).
|
||
final musicLatest = captured.singleWhere(
|
||
(uri) => uri.path == '/Users/user-1/Items/Latest' && uri.queryParameters['ParentId'] == 'music',
|
||
);
|
||
expect(musicLatest.queryParameters['Fields'], 'PremiereDate,OriginalTitle,SortName');
|
||
expect(musicLatest.queryParameters['EnableUserData'], 'false');
|
||
// Video Latest rows carry the same risk: `/Items/Latest` groups a TV
|
||
// library by series, so the rows are Series FOLDER dtos and the count
|
||
// fields cost a per-row COUNT each (#1784). Watch state survives via
|
||
// UserData.UnplayedItemCount, so the hub row set asks for neither.
|
||
final movieLatest = captured.singleWhere(
|
||
(uri) => uri.path == '/Users/user-1/Items/Latest' && uri.queryParameters['ParentId'] == 'movies',
|
||
);
|
||
expect(movieLatest.queryParameters['Fields'], 'Overview');
|
||
expect(movieLatest.queryParameters.containsKey('EnableUserData'), isFalse);
|
||
});
|
||
|
||
test('music library recommendations retain recently and most-played rows', () async {
|
||
final captured = <Uri>[];
|
||
final client = JellyfinClient.forTesting(
|
||
connection: _conn(),
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/Users/user-1/Items/Latest') {
|
||
return _json([
|
||
{'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Latest Album', 'ParentLibraryId': 'music'},
|
||
]);
|
||
}
|
||
if (req.url.path == '/Items' && req.url.queryParameters['Filters'] == 'IsPlayed') {
|
||
final sortBy = req.url.queryParameters['SortBy'];
|
||
return _json({
|
||
'Items': [
|
||
{
|
||
'Id': sortBy == 'DatePlayed' ? 'recent-track' : 'most-played-track',
|
||
'Type': 'Audio',
|
||
'Name': sortBy == 'DatePlayed' ? 'Recent Track' : 'Most Played Track',
|
||
'ParentLibraryId': 'music',
|
||
},
|
||
],
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
|
||
final hubs = await client.fetchLibraryHubs(
|
||
'music',
|
||
libraryName: 'Music',
|
||
libraryKind: MediaKind.artist,
|
||
includePlaybackHubs: true,
|
||
);
|
||
|
||
expect(hubs.map((hub) => hub.identifier), [
|
||
'library.music.latestalbums',
|
||
'library.music.recentlyplayed',
|
||
'library.music.mostplayed',
|
||
]);
|
||
final playedQueries = captured
|
||
.where((uri) => uri.path == '/Items' && uri.queryParameters['Filters'] == 'IsPlayed')
|
||
.toList();
|
||
expect(playedQueries.map((uri) => uri.queryParameters['SortBy']), ['DatePlayed', 'PlayCount']);
|
||
// Audio LEAF dtos: UserData stays (cheap, drives play state); the
|
||
// folder count fields and Overview are dropped.
|
||
expect(playedQueries.map((uri) => uri.queryParameters['Fields']).toSet(), {
|
||
'UserData,PremiereDate,OriginalTitle,SortName',
|
||
});
|
||
});
|
||
|
||
test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async {
|
||
final captured = <Uri>[];
|
||
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
promotedHubKey: '/hubs/promoted',
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/library/sections') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Directory': [
|
||
{'key': '2', 'type': 'show', 'title': 'TV Shows'},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/hubs/promoted') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/recentlyAdded?type=2',
|
||
'title': 'Recently Added TV',
|
||
'type': 'mixed',
|
||
'hubIdentifier': 'home.television.recent',
|
||
'size': 7,
|
||
'more': true,
|
||
'Metadata': [
|
||
for (var i = 1; i <= 7; i++)
|
||
{
|
||
'ratingKey': 'show-$i',
|
||
'type': 'show',
|
||
'title': 'Show $i',
|
||
'librarySectionID': i,
|
||
'librarySectionTitle': 'Library $i',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final result = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false);
|
||
final hubs = result.hubs;
|
||
|
||
expect(result.succeededServerIds, {'plex-1'});
|
||
expect(hubs, hasLength(1));
|
||
expect(hubs.single.title, 'Recently Added TV');
|
||
expect(hubs.single.identifier, 'home.television.recent');
|
||
expect(hubs.single.libraryId, isNull);
|
||
expect(hubs.single.items, hasLength(7));
|
||
// Library prefetch (music detection) + the promoted hubs — no
|
||
// per-library hub calls without a music section.
|
||
expect(captured.map((uri) => uri.path), ['/library/sections', '/hubs/promoted']);
|
||
expect(
|
||
captured.singleWhere((uri) => uri.path == '/hubs/promoted').queryParameters['count'],
|
||
defaultHubPreviewLimit.toString(),
|
||
);
|
||
});
|
||
|
||
test('Plex home layout appends music library hubs the promoted endpoint excludes', () async {
|
||
final captured = <Uri>[];
|
||
|
||
final client = testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'token',
|
||
clientIdentifier: 'client-id',
|
||
product: 'Plezy',
|
||
version: 'test',
|
||
),
|
||
serverId: ServerId('plex-1'),
|
||
serverName: 'Plex',
|
||
promotedHubKey: '/hubs/promoted',
|
||
httpClient: MockClient((req) async {
|
||
captured.add(req.url);
|
||
if (req.url.path == '/library/sections') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Directory': [
|
||
{'key': '1', 'type': 'movie', 'title': 'Movies'},
|
||
{'key': '9', 'type': 'artist', 'title': 'Music'},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/hubs/promoted') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/hubs/home/recentlyAdded?type=1',
|
||
'title': 'Recently Added Movies',
|
||
'type': 'movie',
|
||
'hubIdentifier': 'home.movies.recent',
|
||
'size': 1,
|
||
'Metadata': [
|
||
{'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie', 'librarySectionID': 1},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
if (req.url.path == '/hubs/sections/9') {
|
||
return _json({
|
||
'MediaContainer': {
|
||
'Hub': [
|
||
{
|
||
'key': '/library/sections/9/recentlyAdded',
|
||
'title': 'Recently Added Music',
|
||
'type': 'album',
|
||
'hubIdentifier': 'music.recent',
|
||
'size': 1,
|
||
'Metadata': [
|
||
{'ratingKey': 'album-1', 'type': 'album', 'title': 'Album', 'librarySectionID': 9},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return http.Response('unexpected request', 500);
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
manager.debugRegisterClientForTesting(client);
|
||
|
||
final result = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false);
|
||
final hubs = result.hubs;
|
||
|
||
expect(result.succeededServerIds, {'plex-1'});
|
||
expect(hubs.map((h) => h.identifier), ['home.movies.recent', 'music.recent']);
|
||
expect(hubs[1].items.single.kind, MediaKind.album);
|
||
// Only the music section gets a per-library hub call — the movie
|
||
// library's rows already came from the promoted endpoint.
|
||
expect(captured.where((uri) => uri.path.startsWith('/hubs/sections/')).map((uri) => uri.path), [
|
||
'/hubs/sections/9',
|
||
]);
|
||
});
|
||
});
|
||
}
|