Files
plezy/test/services/jellyfin_external_id_lookup_test.dart
edde746 bc0d14a749 fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore
"In these libraries" chooser could never show more than one copy per server.
A movie held by both a 4K library and an HD library on one Plex server
therefore resolved to whichever copy came back first, with no way to reach
the other.

Return every id-verified match instead. `/library/all` is already
server-wide and each `Metadata` entry carries its own `librarySectionID`,
so both copies come back labelled with no extra request; Plex was simply
taking `Metadata[0]` and the title ladder was returning on its first hit.
An exact-guid hit no longer short-circuits the title search either — a
library still on a legacy agent has a different primary guid and is
invisible to the `guid=` filter.

Copies are deduped by global key and ordered best-first, and each row now
states its resolution, since library names need not mention it.

Resolution passes merge rather than replace: the cross-server fan-out logs
and skips per-server failures, so a later pass can come back short a server
that answered an earlier one, and a failed pass no longer claims the title
left the library. Duplicate keys fold field by field, because Jellyfin's
library stamp is a best-effort ancestors lookup that returns the item bare
when it fails and an unstamped row is indistinguishable from its sibling.
Focus nodes are keyed by copy and reclaimed after a merge re-sorts the
rows, so a dpad user is not thrown to a different copy.

close #1754
2026-08-02 06:40:04 +02:00

230 lines
7.4 KiB
Dart

import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/utils/external_ids.dart';
import '../test_helpers/backend_client_fixtures.dart';
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
Map<String, dynamic> _series({String id = 'series-1', int tmdb = 42}) => {
'Id': id,
'Type': 'Series',
'Name': 'Parent Series',
'ProviderIds': {'Tmdb': '$tmdb'},
};
void main() {
late AppDatabase db;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(db);
});
tearDown(() async {
await db.close();
});
test('tries later titles and verifies the matching provider id', () async {
final searchTerms = <String>[];
final client = testJellyfinClient(
httpClient: MockClient((request) async {
if (request.url.path == '/Items') {
final searchTerm = request.url.queryParameters['SearchTerm']!;
searchTerms.add(searchTerm);
return _json({
'Items': searchTerm == 'Parent Series' ? [_series()] : <Object>[],
});
}
if (request.url.path == '/Items/series-1/Ancestors') {
return _json([
{'Id': 'library-1', 'Name': 'Shows', 'Type': 'CollectionFolder'},
]);
}
fail('Unexpected request: ${request.url}');
}),
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.show,
titles: const ['Parent Series Season 2', 'Parent 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');
});
test('rejects a title hit whose provider ids do not intersect', () async {
final client = testJellyfinClient(
httpClient: MockClient((request) async {
expect(request.url.path, '/Items');
return _json({
'Items': [_series(tmdb: 99)],
});
}),
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.show,
titles: const ['Parent Series'],
);
expect(matches, isEmpty);
});
test('uses the year window only for the first title and broadens the later limit', () async {
final searches = <Uri>[];
final client = testJellyfinClient(
httpClient: MockClient((request) async {
if (request.url.path == '/Items') {
searches.add(request.url);
final searchTerm = request.url.queryParameters['SearchTerm'];
return _json({
'Items': searchTerm == 'Parent Series' ? [_series()] : <Object>[],
});
}
if (request.url.path == '/Items/series-1/Ancestors') return _json(<Object>[]);
fail('Unexpected request: ${request.url}');
}),
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.show,
titles: const ['Parent Series Season 2', 'Parent Series'],
year: 2024,
);
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 {
Future<List<String>> lookupWithSeasons(List<int> seasonNumbers) async {
final client = testJellyfinClient(
httpClient: MockClient((request) async {
if (request.url.path == '/Items') {
return _json({
'Items': [_series()],
});
}
if (request.url.path == '/Shows/series-1/Seasons') {
return _json({
'Items': [
for (final number in seasonNumbers)
{'Id': 'season-$number', 'Type': 'Season', 'Name': 'Season $number', 'IndexNumber': number},
],
});
}
if (request.url.path == '/Items/series-1/Ancestors') return _json(<Object>[]);
fail('Unexpected request: ${request.url}');
}),
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.show,
titles: const ['Parent Series'],
year: 2024,
season: const ExternalSeasonRef(tvdb: 2, tmdb: 2),
);
return [for (final item in matches) item.id];
}
expect(await lookupWithSeasons([1]), isEmpty);
expect(await lookupWithSeasons([1, 2]), ['series-1']);
});
test('does not gate when TVDB and TMDB seasons disagree and Jellyfin order is unknown', () async {
final client = testJellyfinClient(
httpClient: MockClient((request) async {
if (request.url.path == '/Items') {
return _json({
'Items': [_series()],
});
}
if (request.url.path == '/Items/series-1/Ancestors') return _json(<Object>[]);
fail('Season hierarchy must not be requested: ${request.url}');
}),
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 42),
kind: MediaKind.show,
titles: const ['Parent Series'],
season: const ExternalSeasonRef(tvdb: 2, tmdb: 1),
);
expect(matches.map((item) => item.id), ['series-1']);
});
test('returns every library copy of a title, each stamped with its own library', () async {
final client = testJellyfinClient(
httpClient: MockClient((request) async {
if (request.url.path == '/Items') {
return _json({
'Items': [
{
'Id': 'movie-4k',
'Type': 'Movie',
'Name': 'The Matrix',
'ProviderIds': {'Tmdb': '603'},
},
{
'Id': 'movie-hd',
'Type': 'Movie',
'Name': 'The Matrix',
'ProviderIds': {'Tmdb': '603'},
},
],
});
}
if (request.url.path == '/Items/movie-4k/Ancestors') {
return _json([
{'Id': 'library-4k', 'Name': 'Movies 4K', 'Type': 'CollectionFolder'},
]);
}
if (request.url.path == '/Items/movie-hd/Ancestors') {
return _json([
{'Id': 'library-hd', 'Name': 'Movies', 'Type': 'CollectionFolder'},
]);
}
fail('Unexpected request: ${request.url}');
}),
);
addTearDown(client.close);
final matches = await client.findByExternalIds(
const ExternalIds(tmdb: 603),
kind: MediaKind.movie,
titles: const ['The Matrix'],
);
expect(matches.map((item) => (item.id, item.libraryId, item.libraryTitle)), [
('movie-4k', 'library-4k', 'Movies 4K'),
('movie-hd', 'library-hd', 'Movies'),
]);
});
}