fix(explore): match Plex Discover titles to the library again

Two defects sank Explore's Plex integration. Discover started rejecting
X-Plex-Container-Size=500 with a 400, so the watchlist membership
snapshot never loaded: hearts stayed unknown and toggles dead. The
snapshot now pages at 100, and getWatchlist refetches a rejected page in
chunks of the row fetch's field-proven 25, so the next cap drift degrades
gracefully instead of failing and callers' offset math survives either
way.

Worse, every Plex catalog item reached the library matcher carrying only
its Discover rating key: listings were fetched without includeGuids, so
the lookup rested entirely on exact plex:// guid equality between two
metadata universes (Discover duplicate entries break it, notoriously for
anime), and the title fallback can never confirm a candidate without
external ids to intersect - "Not in your library" for owned titles the
MAL provider matched fine. Discover listings now request Guids, the
detail screen re-runs the matcher when enrichment gains id forms
(generation-guarded so the slower bare lookup cannot overwrite the
richer verdict), the matcher keys its memo by id fingerprint so the poor
form's cached negative cannot answer for the rich one, and the Plex
client stops burning title requests that external-id verification is
guaranteed to reject.

Discover requests are now logged like every other API surface; this bug
shipped blind because they were not.

close #1715
This commit is contained in:
edde746
2026-07-30 01:33:46 +02:00
parent 53288116fe
commit 1bf7aac75b
9 changed files with 268 additions and 12 deletions
@@ -243,6 +243,43 @@ void main() {
expect(call.plexGuid, 'plex://movie/5d776828880197001ec90e13');
});
test('an id-poor negative does not suppress the detail-enriched retry', () async {
// #1715: a Plex Discover row item carries only its rating key, and the
// exact-guid lookup can miss even for owned titles (Discover dupes).
// The detail body brings the external ids moments later; that richer
// lookup must reach the servers instead of the bare form's cached
// negative.
final harness = _Harness();
addTearDown(harness.dispose);
final hit = testMediaItem(id: 'server-movie', kind: MediaKind.movie);
harness.aggregation.responses.addAll([
const [],
[hit],
]);
const bare = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
title: 'Night on the Galactic Railroad',
ids: CatalogItemIds(plex: '5d776b59ad5437001f79c6f8'),
);
const enriched = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
title: 'Night on the Galactic Railroad',
ids: CatalogItemIds(plex: '5d776b59ad5437001f79c6f8', imdb: 'tt0089445', tmdb: 34523),
);
expect(bare.entryIdentityKey, enriched.entryIdentityKey);
expect(await harness.matcher.match(bare), isEmpty);
expect((await harness.matcher.match(enriched)).single, same(hit));
expect(harness.aggregation.calls, hasLength(2));
expect(harness.aggregation.calls.last.ids.imdb, 'tt0089445');
// Both forms stay memoized independently.
expect((await harness.matcher.match(enriched)).single, same(hit));
expect(harness.aggregation.calls, hasLength(2));
});
test('only a Plex Discover item contributes a guid, and it costs no request', () async {
final harness = _Harness();
addTearDown(harness.dispose);
@@ -83,6 +83,7 @@ void main() {
expect(captured.url.path, '/library/sections/watchlist/all');
expect(captured.url.queryParameters['X-Plex-Container-Start'], '25');
expect(captured.url.queryParameters['X-Plex-Container-Size'], '25');
expect(captured.url.queryParameters['includeGuids'], '1');
expect(captured.url.queryParameters['includeMeta'], '1');
expect(captured.headers['X-Plex-Token'], 'profile-token');
expect(captured.headers['X-Plex-Client-Identifier'], 'client-id');
@@ -485,6 +486,72 @@ void main() {
await source.removeFromWatchlist(MediaKind.movie, ids);
expect(source.isOnWatchlist(MediaKind.movie, ids), isFalse);
expect(requests, hasLength(2));
// The snapshot page must stay under Discover's container-size cap
// (#1715: 500 was rejected outright).
expect(requests.first.url.queryParameters['X-Plex-Container-Size'], '100');
});
test('an oversized watchlist page refetches as chunks when Discover rejects it', () async {
final requests = <http.Request>[];
final client = PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requests.add(request);
final size = int.parse(request.url.queryParameters['X-Plex-Container-Size']!);
if (size > 25) {
return jsonResponse({
'Error': {'message': 'Invalid value provided for x-plex-container-size!'},
}, status: 400);
}
final start = int.parse(request.url.queryParameters['X-Plex-Container-Start']!);
const total = 180;
final count = (total - start).clamp(0, size);
return jsonResponse({
'MediaContainer': {
'totalSize': total,
'Metadata': [
for (var i = 0; i < count; i++)
{'ratingKey': 'rk-${start + i}', 'type': 'movie', 'title': 'Movie ${start + i}'},
],
},
});
}),
);
addTearDown(client.dispose);
final page = await client.getWatchlist(page: 2, limit: 100);
// The rejected request, then the same range in proven-size chunks:
// the caller's offset math survives the cap drift.
expect(
requests.map(
(request) =>
(request.url.queryParameters['X-Plex-Container-Start'], request.url.queryParameters['X-Plex-Container-Size']),
),
[('100', '100'), ('100', '25'), ('125', '25'), ('150', '25'), ('175', '25')],
);
expect(page.items, hasLength(80));
expect(page.items.first['ratingKey'], 'rk-100');
expect(page.items.last['ratingKey'], 'rk-179');
expect(page.hasMore, isFalse);
expect(page.totalResults, 180);
});
test('other Discover rejections surface instead of chunking', () async {
var requestCount = 0;
final client = PlexDiscoverClient(
_session,
httpClient: MockClient((request) async {
requestCount++;
return jsonResponse({
'Error': {'message': 'Maintenance'},
}, status: 400);
}),
);
addTearDown(client.dispose);
await expectLater(client.getWatchlist(limit: 100), throwsA(isA<PlexDiscoverException>()));
expect(requestCount, 1);
});
test('watchlist mutation resolves a missing Plex rating key from external ids', () async {
@@ -288,6 +288,34 @@ void main() {
expect(requests.single.queryParameters.containsKey('title'), isFalse);
});
test('a guid-only lookup stops after the exact guid miss', () async {
// Title attempts verify candidates by external-id intersection, so with
// no external ids they can never confirm anything — the guid miss must
// be the lookup's only request (#1715).
final requests = <Uri>[];
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
return _json({
'MediaContainer': {'Metadata': <Object>[]},
});
},
);
addTearDown(client.close);
final match = await client.findByExternalIds(
const ExternalIds(),
kind: MediaKind.movie,
titles: const ['Night on the Galactic Railroad'],
plexGuid: 'plex://movie/5d776b59ad5437001f79c6f8',
);
expect(match, isNull);
expect(requests, hasLength(1));
expect(requests.single.queryParameters['guid'], 'plex://movie/5d776b59ad5437001f79c6f8');
expect(requests.single.queryParameters.containsKey('title'), isFalse);
});
test('an agreed season ref gates on the season hierarchy and nothing else', () async {
final childRequests = <String>[];
final extraRequests = <String>[];