@@ -493,12 +493,13 @@ abstract class MediaServerClient {
|
||||
/// Reverse lookup: find a library movie/show matching any of [ids].
|
||||
/// Both backends search by [title] (narrowed by a ±1 [year] window when
|
||||
/// known, with an unfiltered fallback) and verify candidates against
|
||||
/// their exact external ids — Plex against the `Guid` array (its `guid=`
|
||||
/// field filter only matches the primary `plex://` guid), Jellyfin
|
||||
/// against the inline `ProviderIds`. False negatives possible on
|
||||
/// differing titles, false positives never. Returns null when this
|
||||
/// server has no match or [kind] is not movie/show. Used to match
|
||||
/// external catalog items (Explore tab) back to the user's libraries.
|
||||
/// their exact external ids. Plex checks its modern `Guid` array first,
|
||||
/// then recognized legacy scalar `guid` formats; its `guid=` field filter
|
||||
/// only matches the primary `plex://` guid. Jellyfin checks the inline
|
||||
/// `ProviderIds`. False negatives remain possible on differing titles,
|
||||
/// but title alone never produces a match. Returns null when this server
|
||||
/// has no match or [kind] is not movie/show. Used to match external catalog
|
||||
/// items (Explore tab) back to the user's libraries.
|
||||
Future<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year});
|
||||
|
||||
/// Chapters and intro/credits markers for [itemId]. Plex returns both in one
|
||||
|
||||
@@ -3554,12 +3554,12 @@ class PlexClient
|
||||
return ExternalIds.fromGuids(guids);
|
||||
}
|
||||
|
||||
/// Server-wide title filter + client-side Guid-array verification. Plex's
|
||||
/// Server-wide title filter + client-side external-ID verification. Plex's
|
||||
/// `guid=` field filter matches only the item's primary `plex://` guid
|
||||
/// (verified against PMS 1.43) — never the external `imdb://`/`tmdb://`
|
||||
/// ids in the `Guid` array — so this mirrors the Jellyfin
|
||||
/// title-search-and-verify approach: exact-id verification, false
|
||||
/// negatives possible on differing titles, false positives never.
|
||||
/// ids in the modern `Guid` array — so this mirrors the Jellyfin
|
||||
/// title-search-and-verify approach. Recognized legacy scalar `guid` values
|
||||
/// are checked only after modern verification fails.
|
||||
///
|
||||
/// When [year] is known, a ±1 window is applied server-side first
|
||||
/// (`year=` takes comma-separated values as OR, verified on PMS 1.43) so
|
||||
@@ -3575,7 +3575,7 @@ class PlexClient
|
||||
};
|
||||
if (plexType == null || !ids.hasAny || title == null || title.isEmpty) return null;
|
||||
|
||||
Future<MediaItem?> attempt(String? years) async {
|
||||
Future<({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})> attempt(String? years) async {
|
||||
final response = await _getWithFailover(
|
||||
'/library/all',
|
||||
queryParameters: {
|
||||
@@ -3588,23 +3588,32 @@ class PlexClient
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
final metadata = container?['Metadata'];
|
||||
if (metadata is! List) return null;
|
||||
if (metadata is! List) return (modern: null, legacy: null);
|
||||
|
||||
Map<String, dynamic>? legacy;
|
||||
for (final item in metadata) {
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
final guids = item['Guid'];
|
||||
if (guids is! List) continue;
|
||||
if (ids.intersects(ExternalIds.fromGuids(guids))) {
|
||||
return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(item));
|
||||
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
|
||||
return (modern: item, legacy: legacy);
|
||||
}
|
||||
if (legacy == null && ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
|
||||
legacy = item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return (modern: null, legacy: legacy);
|
||||
}
|
||||
|
||||
({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})? filtered;
|
||||
if (year != null) {
|
||||
final match = await attempt('${year - 1},$year,${year + 1}');
|
||||
if (match != null) return match;
|
||||
filtered = await attempt('${year - 1},$year,${year + 1}');
|
||||
final modern = filtered.modern;
|
||||
if (modern != null) return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(modern));
|
||||
}
|
||||
return attempt(null);
|
||||
|
||||
final unfiltered = await attempt(null);
|
||||
final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy;
|
||||
return match == null ? null : PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(match));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// External IDs (IMDb / TMDB / TVDB) extracted from a media server's
|
||||
/// metadata. Shared by the Trakt and tracker resolvers.
|
||||
///
|
||||
/// - **Plex** stores them in a `Guid` array (`imdb://tt123`,
|
||||
/// `tmdb://456`, `tvdb://789`) — fetched via
|
||||
/// [PlexClient.fetchExternalGuids]. Use [ExternalIds.fromGuids].
|
||||
/// - **Plex** stores modern IDs in a `Guid` array (`imdb://tt123`,
|
||||
/// `tmdb://456`, `tvdb://789`) and some legacy agents expose one scalar
|
||||
/// `guid`. Use [ExternalIds.fromGuids] or [ExternalIds.fromLegacyPlexGuid].
|
||||
/// - **Jellyfin** stores them inline as a `ProviderIds` map on every
|
||||
/// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds].
|
||||
class ExternalIds {
|
||||
@@ -42,6 +42,57 @@ class ExternalIds {
|
||||
return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
|
||||
}
|
||||
|
||||
/// Build from a legacy Plex item's scalar `guid`.
|
||||
///
|
||||
/// Only agent formats that map directly to IMDb, TMDB, or TVDB are
|
||||
/// recognized. HAMA AniDB identifiers require an external mapping and are
|
||||
/// deliberately left unsupported here.
|
||||
factory ExternalIds.fromLegacyPlexGuid(Object? guid) {
|
||||
if (guid is! String || guid.isEmpty) return const ExternalIds();
|
||||
|
||||
final uri = Uri.tryParse(guid);
|
||||
if (uri == null || !uri.hasAuthority || uri.path.isNotEmpty) return const ExternalIds();
|
||||
|
||||
final value = uri.host;
|
||||
switch (uri.scheme.toLowerCase()) {
|
||||
case 'com.plexapp.agents.imdb':
|
||||
return ExternalIds(imdb: _normalizeImdb(value, allowBareDigits: false));
|
||||
case 'com.plexapp.agents.themoviedb':
|
||||
return ExternalIds(tmdb: _parseNumericId(value));
|
||||
case 'com.plexapp.agents.thetvdb':
|
||||
return ExternalIds(tvdb: _parseNumericId(value));
|
||||
case 'com.plexapp.agents.hama':
|
||||
final separator = value.indexOf('-');
|
||||
if (separator <= 0 || separator == value.length - 1) return const ExternalIds();
|
||||
final source = value.substring(0, separator).toLowerCase();
|
||||
final id = value.substring(separator + 1);
|
||||
if (source == 'imdb') {
|
||||
return ExternalIds(imdb: _normalizeImdb(id, allowBareDigits: true));
|
||||
}
|
||||
if (source == 'tmdb' || source == 'tsdb') {
|
||||
return ExternalIds(tmdb: _parseNumericId(id));
|
||||
}
|
||||
if (_hamaTvdbSource.hasMatch(source)) {
|
||||
return ExternalIds(tvdb: _parseNumericId(id));
|
||||
}
|
||||
}
|
||||
return const ExternalIds();
|
||||
}
|
||||
|
||||
static final RegExp _decimalId = RegExp(r'^[0-9]+$');
|
||||
static final RegExp _hamaTvdbSource = RegExp(r'^tvdb(?:[2-9])?$');
|
||||
|
||||
static int? _parseNumericId(String value) => _decimalId.hasMatch(value) ? int.tryParse(value) : null;
|
||||
|
||||
static String? _normalizeImdb(String value, {required bool allowBareDigits}) {
|
||||
final normalized = value.toLowerCase();
|
||||
if (normalized.startsWith('tt')) {
|
||||
final digits = normalized.substring(2);
|
||||
return _decimalId.hasMatch(digits) ? 'tt$digits' : null;
|
||||
}
|
||||
return allowBareDigits && _decimalId.hasMatch(normalized) ? 'tt$normalized' : null;
|
||||
}
|
||||
|
||||
/// Pick the first raw Jellyfin item whose inline `ProviderIds` intersect
|
||||
/// [ids]. Pure helper so the reverse-lookup verification stays
|
||||
/// unit-testable (its call site lives in a part file).
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/media/media_kind.dart';
|
||||
import 'package:plezy/services/plex_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'});
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('falls back to an official legacy scalar guid without changing the request contract', () 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'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final match = await client.findByExternalIds(
|
||||
const ExternalIds(imdb: 'tt29768334'),
|
||||
kind: MediaKind.movie,
|
||||
title: 'Legacy Movie',
|
||||
);
|
||||
|
||||
expect(match?.id, 'legacy-movie');
|
||||
expect(match?.libraryId, '5');
|
||||
expect(match?.libraryTitle, 'Legacy Movies');
|
||||
expect(match?.serverId, 'server-1');
|
||||
expect(match?.serverName, 'Server');
|
||||
expect(requestUri.path, '/library/all');
|
||||
expect(requestUri.queryParameters['title'], 'Legacy Movie');
|
||||
expect(requestUri.queryParameters['type'], '1');
|
||||
expect(requestUri.queryParameters['includeGuids'], '1');
|
||||
expect(requestUri.queryParameters['X-Plex-Container-Size'], '20');
|
||||
expect(requestUri.queryParameters.containsKey('guid'), isFalse);
|
||||
});
|
||||
|
||||
test('matches a HAMA show guid', () async {
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final match = await client.findByExternalIds(
|
||||
const ExternalIds(tvdb: 315500),
|
||||
kind: MediaKind.show,
|
||||
title: 'Legacy Show',
|
||||
);
|
||||
|
||||
expect(match?.id, 'legacy-show');
|
||||
});
|
||||
|
||||
test('prefers a modern Guid match over an earlier legacy candidate', () 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'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final match = await client.findByExternalIds(
|
||||
const ExternalIds(imdb: 'tt12345'),
|
||||
kind: MediaKind.movie,
|
||||
title: 'Duplicate',
|
||||
);
|
||||
|
||||
expect(match?.id, 'modern-match');
|
||||
});
|
||||
|
||||
test('prefers an unfiltered modern match over a year-filtered legacy candidate', () 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 match = await client.findByExternalIds(
|
||||
const ExternalIds(tmdb: 42),
|
||||
kind: MediaKind.movie,
|
||||
title: 'Missing Year',
|
||||
year: 2024,
|
||||
);
|
||||
|
||||
expect(match?.id, 'unfiltered-modern');
|
||||
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},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final match = await client.findByExternalIds(
|
||||
const ExternalIds(tvdb: 315500),
|
||||
kind: MediaKind.show,
|
||||
title: 'Unsupported',
|
||||
);
|
||||
|
||||
expect(match, isNull);
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,68 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('ExternalIds.fromLegacyPlexGuid', () {
|
||||
test('normalizes official Plex agent GUIDs', () {
|
||||
final cases = <({String guid, String? imdb, int? tmdb, int? tvdb})>[
|
||||
(guid: 'com.plexapp.agents.imdb://tt29768334?lang=en', imdb: 'tt29768334', tmdb: null, tvdb: null),
|
||||
(guid: 'com.plexapp.agents.themoviedb://1241983', imdb: null, tmdb: 1241983, tvdb: null),
|
||||
(guid: 'com.plexapp.agents.thetvdb://315500?lang=en', imdb: null, tmdb: null, tvdb: 315500),
|
||||
];
|
||||
|
||||
for (final testCase in cases) {
|
||||
final ids = ExternalIds.fromLegacyPlexGuid(testCase.guid);
|
||||
expect(
|
||||
(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb),
|
||||
(imdb: testCase.imdb, tmdb: testCase.tmdb, tvdb: testCase.tvdb),
|
||||
reason: testCase.guid,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes HAMA GUID modes with direct external IDs', () {
|
||||
final cases = <({String guid, String? imdb, int? tmdb, int? tvdb})>[
|
||||
(guid: 'com.plexapp.agents.hama://tvdb-315500', imdb: null, tmdb: null, tvdb: 315500),
|
||||
(guid: 'com.plexapp.agents.hama://tvdb2-315500', imdb: null, tmdb: null, tvdb: 315500),
|
||||
(guid: 'com.plexapp.agents.hama://tvdb9-315500', imdb: null, tmdb: null, tvdb: 315500),
|
||||
(guid: 'com.plexapp.agents.hama://tmdb-69346', imdb: null, tmdb: 69346, tvdb: null),
|
||||
(guid: 'com.plexapp.agents.hama://tsdb-69346?lang=en', imdb: null, tmdb: 69346, tvdb: null),
|
||||
(guid: 'com.plexapp.agents.hama://imdb-6455986', imdb: 'tt6455986', tmdb: null, tvdb: null),
|
||||
(guid: 'com.plexapp.agents.hama://imdb-tt6455986', imdb: 'tt6455986', tmdb: null, tvdb: null),
|
||||
];
|
||||
|
||||
for (final testCase in cases) {
|
||||
final ids = ExternalIds.fromLegacyPlexGuid(testCase.guid);
|
||||
expect(
|
||||
(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb),
|
||||
(imdb: testCase.imdb, tmdb: testCase.tmdb, tvdb: testCase.tvdb),
|
||||
reason: testCase.guid,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unsupported agents, AniDB modes, and malformed IDs', () {
|
||||
final invalid = <Object?>[
|
||||
null,
|
||||
315500,
|
||||
'',
|
||||
'not a URI',
|
||||
'plex://movie/abc',
|
||||
'local://315500',
|
||||
'com.plexapp.agents.none://315500',
|
||||
'com.plexapp.agents.hama://anidb-11905',
|
||||
'com.plexapp.agents.hama://tvdb10-315500',
|
||||
'com.plexapp.agents.hama://tvdb-not-a-number',
|
||||
'com.plexapp.agents.hama://tmdb-',
|
||||
'com.plexapp.agents.hama://imdb-not-an-id',
|
||||
'com.plexapp.agents.themoviedb://1241983/extra',
|
||||
];
|
||||
|
||||
for (final guid in invalid) {
|
||||
expect(ExternalIds.fromLegacyPlexGuid(guid).hasAny, isFalse, reason: '$guid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('ExternalIds.fromJellyfinProviderIds', () {
|
||||
test('extracts Tmdb/Imdb/Tvdb (case-insensitive)', () {
|
||||
final ids = ExternalIds.fromJellyfinProviderIds({'Tmdb': '12345', 'Imdb': 'tt99999', 'Tvdb': '777'});
|
||||
|
||||
Reference in New Issue
Block a user