fix(explore): find library copies through the Plex search index and report servers that could not answer

Explore showed "Not in your library" for titles the user owns. Two causes:
Plex's /library/all?title= filter is an ordered word-prefix substring match,
so `Oshi no Ko` never found a library titled `[Oshi no Ko]` and a romaji or
localized title was unreachable once the first candidate hit; and a server
that answered slower than the 10s header budget was treated as a dead
endpoint, dropped from the wave, cached as a negative for ten minutes and
cascaded through its stale LAN candidates.

Plex title candidates now go through /hubs/search with includeGuids, the
full-text index behind Plex's own search, which also covers originalTitle.
Both backends search every candidate concurrently and union the id-verified
copies instead of stopping at the first title that hit; the candidate cap
becomes two title families so a sequel can reach its alternate title. Lookup
requests run under a dedicated deadline with endpoint failover off. The
aggregation layer reports per-server failures, the matcher only memoizes
complete positive waves, and the detail screen shows "Couldn't check N
servers" instead of claiming absence.

close #2098
This commit is contained in:
edde746
2026-09-05 14:44:01 +02:00
parent a629a3407a
commit a2c9bea041
39 changed files with 808 additions and 492 deletions
@@ -23,6 +23,7 @@ import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/catalog_library_matcher.dart';
import 'package:plezy/services/catalog/seerr_catalog_source.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/seerr/seerr_client.dart';
import 'package:plezy/services/seerr/seerr_constants.dart';
@@ -36,6 +37,7 @@ import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
import 'package:provider/provider.dart';
import '../test_helpers/library_lookup.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
@@ -172,7 +174,7 @@ class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher {
final List<MediaItem> matches;
@override
Future<List<MediaItem>> match(CatalogItem item) async => matches;
Future<LibraryLookupResult> match(CatalogItem item) async => libraryLookupResult(matches);
}
/// Matches only items that carry an external id, the way a real lookup for a
@@ -185,9 +187,9 @@ class _ExternalIdGatedMatcher extends CatalogLibraryMatcher {
final List<CatalogItem> calls = [];
@override
Future<List<MediaItem>> match(CatalogItem item) async {
Future<LibraryLookupResult> match(CatalogItem item) async {
calls.add(item);
return item.ids.toExternalIds().hasAny ? [hit] : const [];
return libraryLookupResult(item.ids.toExternalIds().hasAny ? [hit] : const []);
}
}
@@ -196,11 +198,11 @@ class _ExternalIdGatedMatcher extends CatalogLibraryMatcher {
class _ScriptedMatcher extends CatalogLibraryMatcher {
_ScriptedMatcher(super.multiServer, this.passes);
final List<List<MediaItem> Function()> passes;
final List<LibraryLookupResult Function()> passes;
int calls = 0;
@override
Future<List<MediaItem>> match(CatalogItem item) async {
Future<LibraryLookupResult> match(CatalogItem item) async {
final pass = passes[calls < passes.length ? calls : passes.length - 1];
calls++;
return pass();
@@ -449,8 +451,8 @@ void main() {
source,
item: _bareRow,
matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [
() => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')],
() => const [],
() => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')]),
() => libraryLookupResult(const []),
]),
);
@@ -469,7 +471,7 @@ void main() {
source,
item: _bareRow,
matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [
() => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')],
() => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')]),
() => throw StateError('server unreachable'),
]),
);
@@ -479,6 +481,64 @@ void main() {
expect(find.text(t.explore.notInLibrary), findsNothing);
});
testWidgets('a server that could not be asked is reported instead of counted as a miss', (tester) async {
// #2098: a slow or unreachable server is no evidence of absence. With no
// copies found elsewhere, "Not in your library" would be a false claim.
final source = _FakeCatalogSource(detail: const CatalogDetail(item: _enrichedRow));
await _pumpDetail(
tester,
source,
item: _bareRow,
matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [
() => libraryLookupResult(const [], failed: {'server-1'}),
]),
);
expect(find.text(t.explore.notInLibrary), findsNothing);
expect(find.text(t.explore.libraryCheckFailed(n: 1)), findsOneWidget);
});
testWidgets('an unchecked server is noted under the copies other servers found', (tester) async {
final source = _FakeCatalogSource(detail: const CatalogDetail(item: _enrichedRow));
await _pumpDetail(
tester,
source,
item: _bareRow,
matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [
() => libraryLookupResult(
[_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')],
succeeded: {'server-1'},
failed: {'server-2', 'server-3'},
),
]),
);
expect(find.text(t.explore.inTheseLibraries), findsOneWidget);
expect(find.text('Movies'), findsOneWidget);
expect(find.text(t.explore.libraryCheckFailed(n: 2)), findsOneWidget);
});
testWidgets('a server that answers a later pass stops being reported as unchecked', (tester) async {
// The bare-row pass and the enriched pass race; a server that timed out
// on one and answered the other has been checked.
final source = _FakeCatalogSource(detail: const CatalogDetail(item: _enrichedRow));
await _pumpDetail(
tester,
source,
item: _bareRow,
matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [
() => libraryLookupResult(const [], failed: {'server-1'}),
() => libraryLookupResult(const [], succeeded: {'server-1'}),
]),
);
expect(find.text(t.explore.libraryCheckFailed(n: 1)), findsNothing);
expect(find.text(t.explore.notInLibrary), findsOneWidget);
});
testWidgets('a re-resolve that lost its library stamp keeps the one already shown', (tester) async {
// Jellyfin stamps a copy's library with a best-effort ancestors lookup
// that returns the item bare when it fails. A row that fell back to the
@@ -492,8 +552,8 @@ void main() {
source,
item: _bareRow,
matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [
() => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')],
() => [_libraryCopy(id: 'hd-copy', serverName: null)],
() => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')]),
() => libraryLookupResult([_libraryCopy(id: 'hd-copy', serverName: null)]),
]),
);
@@ -515,11 +575,11 @@ void main() {
source,
item: _bareRow,
matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [
() => [_libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k')],
() => [
() => libraryLookupResult([_libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k')]),
() => libraryLookupResult([
_libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k'),
_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080'),
],
]),
]),
);
@@ -539,11 +599,11 @@ void main() {
source,
item: _bareRow,
matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [
() => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')],
() => [
() => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')]),
() => libraryLookupResult([
_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080'),
_libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k'),
],
]),
]),
);
+3 -2
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
@@ -11,6 +10,7 @@ import 'package:plezy/screens/catalog_item_detail_screen.dart';
import 'package:plezy/screens/catalog_search_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/catalog/catalog_library_matcher.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -18,6 +18,7 @@ import 'package:plezy/widgets/app_menu.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:provider/provider.dart';
import '../test_helpers/library_lookup.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
@@ -210,7 +211,7 @@ class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher {
_FakeCatalogLibraryMatcher(super.multiServer);
@override
Future<List<MediaItem>> match(CatalogItem item) async => const [];
Future<LibraryLookupResult> match(CatalogItem item) async => libraryLookupResult(const []);
}
Future<void> _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {required TargetPlatform platform}) async {
@@ -1,5 +1,4 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/providers/multi_server_provider.dart';
@@ -8,6 +7,7 @@ import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/utils/external_ids.dart';
import '../../test_helpers/library_lookup.dart';
import '../../test_helpers/media_items.dart';
class _LookupCall {
@@ -32,10 +32,10 @@ class _FakeDataAggregationService extends DataAggregationService {
_FakeDataAggregationService(super.serverManager);
final List<_LookupCall> calls = [];
final List<List<MediaItem>> responses = [];
final List<LibraryLookupResult> responses = [];
@override
Future<List<MediaItem>> findByExternalIdsAcrossServers(
Future<LibraryLookupResult> findByExternalIdsAcrossServers(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -76,8 +76,8 @@ void main() {
final firstHit = testMediaItem(id: 'server-season-1', kind: MediaKind.show);
final secondHit = testMediaItem(id: 'server-season-2', kind: MediaKind.show);
harness.aggregation.responses.addAll([
[firstHit],
[secondHit],
libraryLookupResult([firstHit]),
libraryLookupResult([secondHit]),
]);
const first = CatalogItem(
source: CatalogSourceId.mal,
@@ -94,9 +94,9 @@ void main() {
expect(first.identityKey, second.identityKey);
expect(first.entryIdentityKey, isNot(second.entryIdentityKey));
expect((await harness.matcher.match(first)).single, same(firstHit));
expect((await harness.matcher.match(second)).single, same(secondHit));
expect((await harness.matcher.match(first)).single, same(firstHit));
expect((await harness.matcher.match(first)).items.single, same(firstHit));
expect((await harness.matcher.match(second)).items.single, same(secondHit));
expect((await harness.matcher.match(first)).items.single, same(firstHit));
expect(harness.aggregation.calls, hasLength(2));
});
@@ -105,8 +105,8 @@ void main() {
addTearDown(harness.dispose);
final anilistHit = testMediaItem(id: 'japanese-title-match', kind: MediaKind.show);
harness.aggregation.responses.addAll([
const [],
[anilistHit],
libraryLookupResult(const []),
libraryLookupResult([anilistHit]),
]);
const malItem = CatalogItem(
source: CatalogSourceId.mal,
@@ -124,8 +124,8 @@ void main() {
);
expect(malItem.entryIdentityKey, anilistItem.entryIdentityKey);
expect(await harness.matcher.match(malItem), isEmpty);
expect((await harness.matcher.match(anilistItem)).single, same(anilistHit));
expect((await harness.matcher.match(malItem)).items, isEmpty);
expect((await harness.matcher.match(anilistItem)).items.single, same(anilistHit));
expect(harness.aggregation.calls, hasLength(2));
expect(harness.aggregation.calls.last.titles, contains('日本語タイトル'));
});
@@ -136,8 +136,8 @@ void main() {
addTearDown(harness.dispose);
final hit = testMediaItem(id: 'new-library-item', kind: MediaKind.show);
harness.aggregation.responses.addAll([
const [],
[hit],
libraryLookupResult(const []),
libraryLookupResult([hit]),
]);
const item = CatalogItem(
source: CatalogSourceId.anilist,
@@ -146,24 +146,59 @@ void main() {
ids: CatalogItemIds(anilist: 1, tmdb: 42),
);
expect(await harness.matcher.match(item), isEmpty);
expect((await harness.matcher.match(item)).items, isEmpty);
now = now.add(CatalogLibraryMatcher.negativeTtl - const Duration(seconds: 1));
expect(await harness.matcher.match(item), isEmpty);
expect((await harness.matcher.match(item)).items, isEmpty);
expect(harness.aggregation.calls, hasLength(1));
now = now.add(const Duration(seconds: 1));
expect((await harness.matcher.match(item)).single, same(hit));
expect((await harness.matcher.match(item)).items.single, same(hit));
expect(harness.aggregation.calls, hasLength(2));
now = now.add(const Duration(days: 30));
expect((await harness.matcher.match(item)).single, same(hit));
expect((await harness.matcher.match(item)).items.single, same(hit));
expect(harness.aggregation.calls, hasLength(2));
});
test('a wave a server sat out is retried after negativeTtl even when it found copies', () async {
// #2098: a slow server timing out is not evidence the title is absent
// there. The degraded answer is shown, with the failed server named, but
// it must not be memoized for the session like an authoritative hit.
var now = DateTime.utc(2026, 7, 28, 12);
final harness = _Harness(now: () => now);
addTearDown(harness.dispose);
final fastCopy = testMediaItem(id: 'fast-copy', serverId: 'fast');
final slowCopy = testMediaItem(id: 'slow-copy', serverId: 'slow');
harness.aggregation.responses.addAll([
libraryLookupResult([fastCopy], succeeded: {'fast'}, failed: {'slow'}),
libraryLookupResult([fastCopy, slowCopy], succeeded: {'fast', 'slow'}),
]);
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Slow Server Movie',
ids: CatalogItemIds(trakt: 9, tmdb: 77),
);
final degraded = await harness.matcher.match(item);
expect(degraded.items, [fastCopy]);
expect(degraded.failedServerIds, {'slow'});
now = now.add(CatalogLibraryMatcher.negativeTtl - const Duration(seconds: 1));
expect(await harness.matcher.match(item), same(degraded));
expect(harness.aggregation.calls, hasLength(1));
now = now.add(const Duration(seconds: 1));
expect((await harness.matcher.match(item)).items, [fastCopy, slowCopy]);
expect(harness.aggregation.calls, hasLength(2));
now = now.add(const Duration(days: 30));
expect((await harness.matcher.match(item)).items, [fastCopy, slowCopy]);
expect(harness.aggregation.calls, hasLength(2), reason: 'every server answered, so the hit is kept');
});
test('forwards season-stripped title candidates and season reference', () async {
final harness = _Harness();
addTearDown(harness.dispose);
harness.aggregation.responses.add(const []);
harness.aggregation.responses.add(libraryLookupResult(const []));
const season = ExternalSeasonRef(tvdb: 2, tmdb: 1);
const item = CatalogItem(
source: CatalogSourceId.mal,
@@ -183,14 +218,20 @@ void main() {
expect(call.year, isNull, reason: '2027 is season two\'s year, not the parent show\'s');
expect(call.plexGuid, isNull);
expect(call.season, same(season));
// Capped at two: the entry's own title and its season-stripped form.
expect(call.titles, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']);
// Two title families, each with its stripped form: the alternate family
// is the only way to a copy filed under the romaji title (#2098).
expect(call.titles, [
'You and I Are Polar Opposites Season 2',
'You and I Are Polar Opposites',
'Seihantai na Kimi to Boku 2nd Season',
'Seihantai na Kimi to Boku',
]);
});
test('keeps the year for an entry that is not a sequel', () async {
final harness = _Harness();
addTearDown(harness.dispose);
harness.aggregation.responses.add(const []);
harness.aggregation.responses.add(libraryLookupResult(const []));
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.show,
@@ -211,7 +252,7 @@ void main() {
// reliably, and the year window around it would exclude the parent show.
final harness = _Harness();
addTearDown(harness.dispose);
harness.aggregation.responses.add(const []);
harness.aggregation.responses.add(libraryLookupResult(const []));
const item = CatalogItem(
source: CatalogSourceId.anilist,
kind: MediaKind.show,
@@ -228,7 +269,7 @@ void main() {
test('constructs a Plex guid for Discover items without external ids', () async {
final harness = _Harness();
addTearDown(harness.dispose);
harness.aggregation.responses.add(const []);
harness.aggregation.responses.add(libraryLookupResult(const []));
const item = CatalogItem(
source: CatalogSourceId.plex,
kind: MediaKind.movie,
@@ -253,8 +294,8 @@ void main() {
addTearDown(harness.dispose);
final hit = testMediaItem(id: 'server-movie', kind: MediaKind.movie);
harness.aggregation.responses.addAll([
const [],
[hit],
libraryLookupResult(const []),
libraryLookupResult([hit]),
]);
const bare = CatalogItem(
source: CatalogSourceId.plex,
@@ -270,20 +311,20 @@ void main() {
);
expect(bare.entryIdentityKey, enriched.entryIdentityKey);
expect(await harness.matcher.match(bare), isEmpty);
expect((await harness.matcher.match(enriched)).single, same(hit));
expect((await harness.matcher.match(bare)).items, isEmpty);
expect((await harness.matcher.match(enriched)).items.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((await harness.matcher.match(enriched)).items.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);
harness.aggregation.responses.addAll([const [], const []]);
harness.aggregation.responses.addAll([libraryLookupResult(const []), libraryLookupResult(const [])]);
// A MAL entry can carry a Plex rating key through cross-source membership,
// but that key is not a Discover guid and must never be synthesised into
// one — the fast path is only sound for items that came from Discover.
@@ -32,7 +32,9 @@ void main() {
await db.close();
});
test('tries later titles and verifies the matching provider id', () async {
test('searches every title and unions the id-verified copies each one reaches', () async {
// #2098: a copy filed under another language's title is only reachable
// through that title, so a title that hit must not stop its siblings.
final searchTerms = <String>[];
final client = testJellyfinClient(
httpClient: MockClient((request) async {
@@ -40,7 +42,11 @@ void main() {
final searchTerm = request.url.queryParameters['SearchTerm']!;
searchTerms.add(searchTerm);
return _json({
'Items': searchTerm == 'Parent Series' ? [_series()] : <Object>[],
'Items': switch (searchTerm) {
'Parent Series' => [_series()],
'Oya Series' => [_series(), _series(id: 'series-romaji')],
_ => <Object>[],
},
});
}
if (request.url.path == '/Items/series-1/Ancestors') {
@@ -48,6 +54,7 @@ void main() {
{'Id': 'library-1', 'Name': 'Shows', 'Type': 'CollectionFolder'},
]);
}
if (request.url.path == '/Items/series-romaji/Ancestors') return _json(<Object>[]);
fail('Unexpected request: ${request.url}');
}),
);
@@ -56,14 +63,13 @@ void main() {
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.show,
titles: const ['Parent Series Season 2', 'Parent Series'],
titles: const ['Parent Series Season 2', 'Parent Series', 'Oya Series'],
);
expect(searchTerms, ['Parent Series Season 2', 'Parent Series']);
expect(matches, hasLength(1));
expect(matches.single.id, 'series-1');
expect(matches.single.libraryId, 'library-1');
expect(matches.single.libraryTitle, 'Shows');
expect(searchTerms, ['Parent Series Season 2', 'Parent Series', 'Oya Series']);
expect(matches.map((item) => item.id), ['series-1', 'series-romaji'], reason: 'the shared copy appears once');
expect(matches.first.libraryId, 'library-1');
expect(matches.first.libraryTitle, 'Shows');
});
test('rejects a title hit whose provider ids do not intersect', () async {
@@ -86,7 +92,7 @@ void main() {
expect(matches, isEmpty);
});
test('uses the year window only for the first title and broadens the later limit', () async {
test('applies the year window to the first title only', () async {
final searches = <Uri>[];
final client = testJellyfinClient(
httpClient: MockClient((request) async {
@@ -113,9 +119,7 @@ void main() {
expect(matches.map((item) => item.id), ['series-1']);
expect(searches, hasLength(2));
expect(searches.first.queryParameters['years'], '2023,2024,2025');
expect(searches.first.queryParameters['Limit'], '20');
expect(searches.last.queryParameters.containsKey('years'), isFalse);
expect(searches.last.queryParameters['Limit'], '50');
});
test('requires an agreed sequel season to exist in the matched series', () async {
+242 -254
View File
@@ -4,6 +4,7 @@ import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/external_ids.dart';
@@ -12,6 +13,40 @@ import '../test_helpers/backend_client_fixtures.dart';
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
/// A `/library/all` page.
http.Response _metadata(List<Object> rows) => _json({
'MediaContainer': {'Metadata': rows},
});
/// A `/hubs/search` answer: the hub of [type] plus the episode hub Plex
/// always adds to a `tv` search, so a lookup that read every hub would leak
/// episodes into the match list.
http.Response _hubs(List<Object> rows, {String type = 'movie'}) => _json({
'MediaContainer': {
'Hub': [
{'type': type, 'hubIdentifier': type, 'Metadata': rows},
{
'type': 'episode',
'hubIdentifier': 'episode',
'Metadata': [
{
'ratingKey': 'stray-episode',
'type': 'episode',
'title': 'Pilot',
'Guid': [
{'id': 'tvdb://123'},
{'id': 'tmdb://42'},
{'id': 'imdb://tt12345'},
],
},
],
},
],
},
});
bool _isTitleSearch(http.Request request) => request.url.path == '/hubs/search';
void main() {
late AppDatabase db;
@@ -24,28 +59,24 @@ void main() {
await db.close();
});
test('falls back to an official legacy scalar guid without changing the request contract', () async {
test('searches the title through the hub search index and reads the legacy scalar guid', () async {
late Uri requestUri;
final client = testPlexClient(
handler: (request) async {
requestUri = request.url;
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'legacy-movie',
'type': 'movie',
'title': 'Legacy Movie',
'librarySectionID': 5,
'librarySectionTitle': 'Legacy Movies',
'guid': 'com.plexapp.agents.imdb://tt29768334?lang=en',
'Guid': [
{'id': 'tmdb://999'},
],
},
return _hubs([
{
'ratingKey': 'legacy-movie',
'type': 'movie',
'title': 'Legacy Movie',
'librarySectionID': 5,
'librarySectionTitle': 'Legacy Movies',
'guid': 'com.plexapp.agents.imdb://tt29768334?lang=en',
'Guid': [
{'id': 'tmdb://999'},
],
},
});
]);
},
);
addTearDown(client.close);
@@ -61,63 +92,63 @@ void main() {
expect(matches.single.libraryTitle, 'Legacy Movies');
expect(matches.single.serverId, 'server-1');
expect(matches.single.serverName, 'Server');
expect(requestUri.path, '/library/all');
expect(requestUri.queryParameters['title'], 'Legacy Movie');
expect(requestUri.queryParameters['type'], '1');
// #2098: `/library/all?title=` is a word-prefix substring match that
// cannot find `[Oshi no Ko]` from `Oshi no Ko`; the search index can.
expect(requestUri.path, '/hubs/search');
expect(requestUri.queryParameters['query'], 'Legacy Movie');
expect(requestUri.queryParameters['searchTypes'], 'movies');
expect(requestUri.queryParameters['includeGuids'], '1');
expect(requestUri.queryParameters['X-Plex-Container-Size'], '20');
expect(requestUri.queryParameters['limit'], '50');
expect(requestUri.queryParameters.containsKey('guid'), isFalse);
expect(requestUri.queryParameters.containsKey('year'), isFalse, reason: 'the index ranks; no year window');
});
test('matches a HAMA show guid', () async {
test('a show search reads the show hub only, never the episode hub', () async {
late Uri requestUri;
final client = testPlexClient(
handler: (request) async => _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'legacy-show',
'type': 'show',
'title': 'Legacy Show',
'guid': 'com.plexapp.agents.hama://tvdb4-315500?lang=en',
},
],
},
}),
handler: (request) async {
requestUri = request.url;
return _hubs(type: 'show', [
{
'ratingKey': 'legacy-show',
'type': 'show',
'title': 'Legacy Show',
'guid': 'com.plexapp.agents.hama://tvdb4-315500?lang=en',
},
]);
},
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tvdb: 315500),
const ExternalIds(tvdb: 315500, tmdb: 42),
kind: MediaKind.show,
titles: const ['Legacy Show'],
);
expect(matches.map((match) => match.id), ['legacy-show']);
expect(requestUri.queryParameters['searchTypes'], 'tv');
});
test('returns both agent variants of one title, modern Guid match first', () async {
final client = testPlexClient(
handler: (request) async => _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'legacy-match',
'type': 'movie',
'title': 'Duplicate',
'guid': 'com.plexapp.agents.imdb://tt12345',
},
{
'ratingKey': 'modern-match',
'type': 'movie',
'title': 'Duplicate',
'guid': 'plex://movie/modern',
'Guid': [
{'id': 'imdb://tt12345'},
],
},
handler: (request) async => _hubs([
{
'ratingKey': 'legacy-match',
'type': 'movie',
'title': 'Duplicate',
'guid': 'com.plexapp.agents.imdb://tt12345',
},
{
'ratingKey': 'modern-match',
'type': 'movie',
'title': 'Duplicate',
'guid': 'plex://movie/modern',
'Guid': [
{'id': 'imdb://tt12345'},
],
},
}),
]),
);
addTearDown(client.close);
@@ -131,67 +162,12 @@ void main() {
expect(matches.map((match) => match.id), ['modern-match', 'legacy-match']);
});
test('unions the year-filtered and unfiltered pages, modern match first', () async {
final requests = <Uri>[];
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
final isFiltered = request.url.queryParameters.containsKey('year');
return _json({
'MediaContainer': {
'Metadata': [
if (isFiltered)
{
'ratingKey': 'filtered-legacy',
'type': 'movie',
'title': 'Missing Year',
'guid': 'com.plexapp.agents.themoviedb://42',
}
else
{
'ratingKey': 'unfiltered-modern',
'type': 'movie',
'title': 'Missing Year',
'guid': 'plex://movie/modern',
'Guid': [
{'id': 'tmdb://42'},
],
},
],
},
});
},
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.movie,
titles: const ['Missing Year'],
year: 2024,
);
expect(matches.map((match) => match.id), ['unfiltered-modern', 'filtered-legacy']);
expect(requests, hasLength(2));
expect(requests.first.queryParameters['year'], '2023,2024,2025');
expect(requests.last.queryParameters.containsKey('year'), isFalse);
});
test('does not match unsupported or malformed scalar GUIDs', () async {
final client = testPlexClient(
handler: (request) async => _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'anidb',
'type': 'show',
'title': 'Unsupported',
'guid': 'com.plexapp.agents.hama://anidb-11905',
},
{'ratingKey': 'wrong-shape', 'type': 'show', 'title': 'Unsupported', 'guid': 315500},
],
},
}),
handler: (request) async => _hubs(type: 'show', [
{'ratingKey': 'anidb', 'type': 'show', 'title': 'Unsupported', 'guid': 'com.plexapp.agents.hama://anidb-11905'},
{'ratingKey': 'wrong-shape', 'type': 'show', 'title': 'Unsupported', 'guid': 315500},
]),
);
addTearDown(client.close);
@@ -204,38 +180,54 @@ void main() {
expect(matches, isEmpty);
});
test('tries broader title candidates in order and still verifies external ids', () async {
test('searches every title candidate and unions their id-verified copies', () async {
// #2098: a copy filed under another language's title is reachable only
// through that title, so a title that hit must not stop its siblings —
// and id verification keeps the wrong show out of every page.
final requests = <Uri>[];
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
if (request.url.queryParameters['title'] == 'Parent Show Season 2') {
return _json({
'MediaContainer': {'Metadata': <Object>[]},
});
}
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'wrong-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://999'},
],
},
{
'ratingKey': 'verified-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://123'},
],
},
],
},
});
return switch (request.url.queryParameters['query']) {
'Parent Show Season 2' => _hubs(type: 'show', const []),
'Parent Show' => _hubs(type: 'show', [
{
'ratingKey': 'wrong-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://999'},
],
},
{
'ratingKey': 'verified-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://123'},
],
},
]),
_ => _hubs(type: 'show', [
{
'ratingKey': 'romaji-parent',
'type': 'show',
'title': 'Oya Bangumi',
'librarySectionTitle': 'Anime (romaji)',
'Guid': [
{'id': 'tvdb://123'},
],
},
{
'ratingKey': 'verified-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://123'},
],
},
]),
};
},
);
addTearDown(client.close);
@@ -243,13 +235,11 @@ void main() {
final matches = await client.findByExternalIds(
const ExternalIds(tvdb: 123),
kind: MediaKind.show,
titles: const ['Parent Show Season 2', 'Parent Show'],
titles: const ['Parent Show Season 2', 'Parent Show', 'Oya Bangumi'],
);
expect(matches.map((match) => match.id), ['verified-parent']);
expect(requests.map((uri) => uri.queryParameters['title']), ['Parent Show Season 2', 'Parent Show']);
expect(requests.first.queryParameters['X-Plex-Container-Size'], '20');
expect(requests.last.queryParameters['X-Plex-Container-Size'], '50');
expect(matches.map((match) => match.id), ['verified-parent', 'romaji-parent']);
expect(requests.map((uri) => uri.queryParameters['query']), ['Parent Show Season 2', 'Parent Show', 'Oya Bangumi']);
});
test('uses an exact Plex guid without external ids or a title query', () async {
@@ -257,18 +247,14 @@ void main() {
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'exact-show',
'type': 'show',
'title': 'Exact Show',
'guid': 'plex://show/5e01fc33932ff9001db3b242',
},
],
return _metadata([
{
'ratingKey': 'exact-show',
'type': 'show',
'title': 'Exact Show',
'guid': 'plex://show/5e01fc33932ff9001db3b242',
},
});
]);
},
);
addTearDown(client.close);
@@ -286,20 +272,18 @@ void main() {
expect(requests.single.queryParameters['guid'], 'plex://show/5e01fc33932ff9001db3b242');
expect(requests.single.queryParameters['type'], '2');
expect(requests.single.queryParameters['includeGuids'], '1');
expect(requests.single.queryParameters.containsKey('title'), isFalse);
expect(requests.single.queryParameters.containsKey('query'), isFalse);
});
test('a guid-only lookup stops after the exact guid miss', () async {
// Title attempts verify candidates by external-id intersection, so with
// Title searches 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>[]},
});
return _metadata(const []);
},
);
addTearDown(client.close);
@@ -314,7 +298,6 @@ void main() {
expect(matches, isEmpty);
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 {
@@ -345,22 +328,18 @@ void main() {
if (request.url.path.startsWith('/library/metadata/')) {
return _json({'MediaContainer': <String, Object?>{}});
}
final complete = request.url.queryParameters['title'] == 'Complete Show';
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': complete ? 'complete-show' : 'incomplete-show',
'type': 'show',
'title': complete ? 'Complete Show' : 'Incomplete Show',
'librarySectionID': 4,
'Guid': [
{'id': 'tvdb://${complete ? 101 : 100}'},
],
},
final complete = request.url.queryParameters['query'] == 'Complete Show';
return _hubs(type: 'show', [
{
'ratingKey': complete ? 'complete-show' : 'incomplete-show',
'type': 'show',
'title': complete ? 'Complete Show' : 'Incomplete Show',
'librarySectionID': 4,
'Guid': [
{'id': 'tvdb://${complete ? 101 : 100}'},
],
},
});
]);
},
);
addTearDown(client.close);
@@ -392,21 +371,17 @@ void main() {
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'ungated-show',
'type': 'show',
'title': 'Ungated Show',
'librarySectionID': 9,
'Guid': [
{'id': 'tvdb://300'},
],
},
return _hubs(type: 'show', [
{
'ratingKey': 'ungated-show',
'type': 'show',
'title': 'Ungated Show',
'librarySectionID': 9,
'Guid': [
{'id': 'tvdb://300'},
],
},
});
]);
},
);
addTearDown(client.close);
@@ -419,7 +394,7 @@ void main() {
);
expect(matches.map((match) => match.id), ['ungated-show']);
expect(requests.map((uri) => uri.path), ['/library/all'], reason: 'no children, no preferences');
expect(requests.map((uri) => uri.path), ['/hubs/search'], reason: 'no children, no preferences');
});
test('returns every library copy sharing one exact Plex guid', () async {
@@ -427,34 +402,30 @@ void main() {
// section and an HD section answers with two sibling entries. Taking
// Metadata[0] is what hid the second copy from the Explore chooser.
final client = testPlexClient(
handler: (request) async => _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'hd-copy',
'type': 'movie',
'title': 'Dual Library',
'guid': 'plex://movie/dual',
'librarySectionID': 1,
'librarySectionTitle': 'Movies',
'Media': [
{'id': 10, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'},
],
},
{
'ratingKey': 'uhd-copy',
'type': 'movie',
'title': 'Dual Library',
'guid': 'plex://movie/dual',
'librarySectionID': 2,
'librarySectionTitle': '4K Movies',
'Media': [
{'id': 20, 'videoResolution': '4k', 'videoCodec': 'hevc', 'container': 'mkv'},
],
},
handler: (request) async => _metadata([
{
'ratingKey': 'hd-copy',
'type': 'movie',
'title': 'Dual Library',
'guid': 'plex://movie/dual',
'librarySectionID': 1,
'librarySectionTitle': 'Movies',
'Media': [
{'id': 10, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'},
],
},
}),
{
'ratingKey': 'uhd-copy',
'type': 'movie',
'title': 'Dual Library',
'guid': 'plex://movie/dual',
'librarySectionID': 2,
'librarySectionTitle': '4K Movies',
'Media': [
{'id': 20, 'videoResolution': '4k', 'videoCodec': 'hevc', 'container': 'mkv'},
],
},
]),
);
addTearDown(client.close);
@@ -489,22 +460,17 @@ void main() {
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
final byGuid = request.url.queryParameters.containsKey('guid');
return _json({
'MediaContainer': {
'Metadata': [
modernCopy,
if (!byGuid)
{
'ratingKey': 'legacy-copy',
'type': 'movie',
'title': 'Mixed Agents',
'guid': 'com.plexapp.agents.imdb://tt777?lang=en',
'librarySectionTitle': 'Movies',
},
],
if (!_isTitleSearch(request)) return _metadata([modernCopy]);
return _hubs([
modernCopy,
{
'ratingKey': 'legacy-copy',
'type': 'movie',
'title': 'Mixed Agents',
'guid': 'com.plexapp.agents.imdb://tt777?lang=en',
'librarySectionTitle': 'Movies',
},
});
]);
},
);
addTearDown(client.close);
@@ -517,7 +483,7 @@ void main() {
);
expect(matches.map((match) => match.id), ['modern-copy', 'legacy-copy']);
expect(requests.map((uri) => uri.queryParameters.containsKey('guid')), [true, false]);
expect(requests.map((uri) => uri.path), ['/library/all', '/hubs/search']);
});
test('season-gates every candidate, not just the first', () async {
@@ -542,30 +508,26 @@ void main() {
if (request.url.path.startsWith('/library/metadata/')) {
return _json({'MediaContainer': <String, Object?>{}});
}
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'partial-copy',
'type': 'show',
'title': 'Split Show',
'librarySectionTitle': 'Shows',
'Guid': [
{'id': 'tvdb://555'},
],
},
{
'ratingKey': 'full-copy',
'type': 'show',
'title': 'Split Show',
'librarySectionTitle': '4K Shows',
'Guid': [
{'id': 'tvdb://555'},
],
},
return _hubs(type: 'show', [
{
'ratingKey': 'partial-copy',
'type': 'show',
'title': 'Split Show',
'librarySectionTitle': 'Shows',
'Guid': [
{'id': 'tvdb://555'},
],
},
});
{
'ratingKey': 'full-copy',
'type': 'show',
'title': 'Split Show',
'librarySectionTitle': '4K Shows',
'Guid': [
{'id': 'tvdb://555'},
],
},
]);
},
);
addTearDown(client.close);
@@ -579,4 +541,30 @@ void main() {
expect(matches.map((match) => match.id), ['full-copy']);
});
test('a server that cannot answer fails the lookup without cascading through its endpoints', () async {
// #2098: a slow or erroring server is not evidence the title is absent —
// the caller must see a failure, not an empty page — and it is not a
// dead endpoint either, so the lookup must not move the client onto the
// next candidate the way a request left on the default policy would.
final requestsByHost = <String, int>{};
var exhausted = 0;
final client = testPlexClient(
baseUrl: 'https://primary.example.com',
prioritizedEndpoints: const ['https://primary.example.com', 'https://secondary.example.com'],
onAllEndpointsExhausted: () => exhausted++,
handler: (request) async {
requestsByHost.update(request.url.host, (count) => count + 1, ifAbsent: () => 1);
return http.Response('busy', 503);
},
);
addTearDown(client.close);
await expectLater(
client.findByExternalIds(const ExternalIds(tmdb: 42), kind: MediaKind.movie, titles: const ['Busy Movie']),
throwsA(isA<MediaServerHttpException>()),
);
expect(requestsByHost, {'primary.example.com': 1});
expect(exhausted, 0);
});
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:plezy/media/media_item.dart';
import 'package:plezy/services/data_aggregation_service.dart';
/// A reverse-lookup wave fixture. By default every listed server answered;
/// name [failed] / [cancelled] servers to model a wave a server sat out.
LibraryLookupResult libraryLookupResult(
List<MediaItem> items, {
Set<String> succeeded = const {},
Set<String> failed = const {},
Set<String> cancelled = const {},
}) => (items: items, succeededServerIds: succeeded, cancelledServerIds: cancelled, failedServerIds: failed);
+19 -12
View File
@@ -50,21 +50,13 @@ void main() {
});
group('titleMatchCandidates', () {
test('emits a title immediately followed by its stripped form', () {
test('emits each title immediately followed by its stripped form', () {
final candidates = titleMatchCandidates([
'You and I Are Polar Opposites Season 2',
'Seihantai na Kimi to Boku 2nd Season',
]);
// Capped at two, so the second provider title never gets a slot — the
// stripped form of the first is worth far more than an alias.
expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']);
});
test('honours a wider cap by interleaving, never by listing raw titles first', () {
final candidates = titleMatchCandidates([
'You and I Are Polar Opposites Season 2',
'Seihantai na Kimi to Boku 2nd Season',
], limit: 4);
// The second family is the only route to a copy filed under the romaji
// title (#2098), and each family carries its own parent-show form.
expect(candidates, [
'You and I Are Polar Opposites Season 2',
'You and I Are Polar Opposites',
@@ -72,6 +64,14 @@ void main() {
'Seihantai na Kimi to Boku',
]);
});
test('honours a narrower cap by interleaving, never by listing raw titles first', () {
final candidates = titleMatchCandidates([
'You and I Are Polar Opposites Season 2',
'Seihantai na Kimi to Boku 2nd Season',
], limit: 2);
expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']);
});
test('normalizes typographic punctuation both backends miss on', () {
// Verified live: Plex and Jellyfin both return 0 for the curly form.
expect(titleMatchCandidates(['Frieren: Beyond Journey\u2019s End']), ["Frieren: Beyond Journey's End"]);
@@ -103,7 +103,14 @@ void main() {
'Mushoku Tensei 2',
'MT2',
]);
expect(candidates, ['Mushoku Tensei: Jobless Reincarnation Season 2', 'Mushoku Tensei: Jobless Reincarnation']);
// `II:` mid-title is not a sequel suffix, so the second family has no
// stripped form and the third alias takes the last slot.
expect(candidates, [
'Mushoku Tensei: Jobless Reincarnation Season 2',
'Mushoku Tensei: Jobless Reincarnation',
'Mushoku Tensei II: Isekai Ittara Honki Dasu',
'Mushoku Tensei 2',
]);
});
});
}