Pausing an episode on one device, finishing it on another and pressing Refresh left the first device showing the old "minutes left". Restarting the app showed the right value. Two independent defects produce that, and either alone reproduces the report. The first is the watch-state overlay. Every local watch event lands in WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites viewOffsetMs unconditionally; isNewerThan only ever orders one patch against another, never against the server row underneath. Nothing expires a patch and nothing clears the map except a profile switch, so the Mac's own paused position kept winning over every subsequent fetch until the process died. A patch exists to bridge the gap between a local action and the next server read of that item, so it should stop applying once that read happens. The store now records the watermark at which a successful authoritative response returned each key, and suppresses an acknowledged session patch at or below it. Only a watermark is stored, never the observed state: WatchStateSnapshot cannot hold a container's leaf counts, and keeping max() per key makes the order two concurrent responses complete irrelevant. Suppression is a read-time predicate, so nothing mutates during build. The barrier covers the parentChain too. patchForItem picks the newest of the item's own entry and its ancestors', so retiring only the item's entry would let an older season mark win and render watched/0 -- worse than either the stale value or the fresh one. An authoritative read of a child already reflects any container mark that preceded it, so the child's observation judges its ancestors as well; a newer container action still wins. Provenance decides what may be suppressed at all. WatchStateEvent now carries serverAcknowledged, defaulting to false so an unclassified emit site degrades to today's behaviour rather than silently becoming retireable. An offline write is owed to the server and a read must never retire it, so it stays until a WatchPatchPromotionNotifier promotion says the queue replayed it. That channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts to watched/unwatched by purging queued progress, so replaying one there would delete a newer rewatch. Promotion matches an exact WatchPatchId -- session minted for live crossings, derived from the persisted (profile, row, revision) for queued ones so it still joins after a restart. Report acceptance is not delivery: PlaybackReportSession resolves true for a same-state startup heartbeat it drops, so acknowledgement now keys on onDelivered. A MediaBrowser Started saves play count and last-played date but not the position, so it cannot acknowledge an offset. No report-derived watched crossing is acknowledged on any backend -- Jellyfin hard-codes its threshold and Plex never loads the server pref that would tell it the real one -- so only an awaited explicit markWatched settles one. The second defect is that a failed Refresh reported success. Plex _fetchHubs and the Jellyfin hub legs both degrade a failure to an empty list, and the library prefetch discarded its failures, so a server whose every hub request failed was recorded as succeeded; DiscoverProvider then kept the previous rows, set loaded and surfaced nothing. Worse, the background Continue Watching refresh wiped the row outright on zero success. Hub legs now report what they degraded through a HubFetchDiagnostics sink, which keeps partial rows alongside the failure and leaves every existing caller untouched. Failures ride through the aggregation results, a leg that could not run because discovery failed contributes that failure rather than a successful no-op, and loaded-server ids became succeeded - failed - cancelled so one bad leg no longer caches a server as covered and blocks its retry. The toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad snackbar on failure while the retained rows stay on screen. Rollback after a mid-pass exception is version-guarded, refilters against the current hidden libraries and no longer publishes a system shelf the pass never committed. Observations are staged with the pass and flushed only once the same disposal, generation and exception checks that authorise committing those rows have passed, so a discarded or rolled-back response can never suppress a patch. Also fixes a live data-loss race the promotion work would have built on: upsertProgressAction stamped a millisecond timestamp and updated the row in place, so a rewatch queued during an in-flight replay was deleted by id. Revisions are now strictly monotonic per row, replay deletes and retry updates compare against them, and the upsert resets the retry fields because a new revision is a new logical action. close #1829
1267 lines
47 KiB
Dart
1267 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);
|
||
}
|
||
|
||
/// Smoke tests for the surviving cross-server aggregation surface on
|
||
/// [DataAggregationService]. Single-server passthroughs were removed in
|
||
/// favour of `context.tryGetMediaClientForServer(...).<method>()`; what's
|
||
/// left here is the multi-client fan-out, which is testable without a
|
||
/// real backend by simply asserting the empty-state behaviour.
|
||
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',
|
||
]);
|
||
});
|
||
});
|
||
}
|