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:
@@ -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'),
|
||||
],
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user