`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
230 lines
7.4 KiB
Dart
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'),
|
|
]);
|
|
});
|
|
}
|