fix(plex): match legacy agent GUIDs

closing #1566
This commit is contained in:
edde746
2026-07-14 18:11:04 +02:00
parent 8c88385977
commit 9d812fa3cc
5 changed files with 350 additions and 22 deletions
+7 -6
View File
@@ -493,12 +493,13 @@ abstract class MediaServerClient {
/// Reverse lookup: find a library movie/show matching any of [ids]. /// Reverse lookup: find a library movie/show matching any of [ids].
/// Both backends search by [title] (narrowed by a ±1 [year] window when /// Both backends search by [title] (narrowed by a ±1 [year] window when
/// known, with an unfiltered fallback) and verify candidates against /// known, with an unfiltered fallback) and verify candidates against
/// their exact external ids Plex against the `Guid` array (its `guid=` /// their exact external ids. Plex checks its modern `Guid` array first,
/// field filter only matches the primary `plex://` guid), Jellyfin /// then recognized legacy scalar `guid` formats; its `guid=` field filter
/// against the inline `ProviderIds`. False negatives possible on /// only matches the primary `plex://` guid. Jellyfin checks the inline
/// differing titles, false positives never. Returns null when this /// `ProviderIds`. False negatives remain possible on differing titles,
/// server has no match or [kind] is not movie/show. Used to match /// but title alone never produces a match. Returns null when this server
/// external catalog items (Explore tab) back to the user's libraries. /// 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}); Future<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year});
/// Chapters and intro/credits markers for [itemId]. Plex returns both in one /// Chapters and intro/credits markers for [itemId]. Plex returns both in one
+22 -13
View File
@@ -3554,12 +3554,12 @@ class PlexClient
return ExternalIds.fromGuids(guids); 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 /// `guid=` field filter matches only the item's primary `plex://` guid
/// (verified against PMS 1.43) — never the external `imdb://`/`tmdb://` /// (verified against PMS 1.43) — never the external `imdb://`/`tmdb://`
/// ids in the `Guid` array — so this mirrors the Jellyfin /// ids in the modern `Guid` array — so this mirrors the Jellyfin
/// title-search-and-verify approach: exact-id verification, false /// title-search-and-verify approach. Recognized legacy scalar `guid` values
/// negatives possible on differing titles, false positives never. /// are checked only after modern verification fails.
/// ///
/// When [year] is known, a ±1 window is applied server-side first /// 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 /// (`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; 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( final response = await _getWithFailover(
'/library/all', '/library/all',
queryParameters: { queryParameters: {
@@ -3588,23 +3588,32 @@ class PlexClient
); );
final container = _getMediaContainer(response); final container = _getMediaContainer(response);
final metadata = container?['Metadata']; 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) { for (final item in metadata) {
if (item is! Map<String, dynamic>) continue; if (item is! Map<String, dynamic>) continue;
final guids = item['Guid']; final guids = item['Guid'];
if (guids is! List) continue; if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
if (ids.intersects(ExternalIds.fromGuids(guids))) { return (modern: item, legacy: legacy);
return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(item)); }
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) { if (year != null) {
final match = await attempt('${year - 1},$year,${year + 1}'); filtered = await attempt('${year - 1},$year,${year + 1}');
if (match != null) return match; 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 @override
+54 -3
View File
@@ -1,9 +1,9 @@
/// External IDs (IMDb / TMDB / TVDB) extracted from a media server's /// External IDs (IMDb / TMDB / TVDB) extracted from a media server's
/// metadata. Shared by the Trakt and tracker resolvers. /// metadata. Shared by the Trakt and tracker resolvers.
/// ///
/// - **Plex** stores them in a `Guid` array (`imdb://tt123`, /// - **Plex** stores modern IDs in a `Guid` array (`imdb://tt123`,
/// `tmdb://456`, `tvdb://789`) — fetched via /// `tmdb://456`, `tvdb://789`) and some legacy agents expose one scalar
/// [PlexClient.fetchExternalGuids]. Use [ExternalIds.fromGuids]. /// `guid`. Use [ExternalIds.fromGuids] or [ExternalIds.fromLegacyPlexGuid].
/// - **Jellyfin** stores them inline as a `ProviderIds` map on every /// - **Jellyfin** stores them inline as a `ProviderIds` map on every
/// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds]. /// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds].
class ExternalIds { class ExternalIds {
@@ -42,6 +42,57 @@ class ExternalIds {
return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); 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 /// Pick the first raw Jellyfin item whose inline `ProviderIds` intersect
/// [ids]. Pure helper so the reverse-lookup verification stays /// [ids]. Pure helper so the reverse-lookup verification stays
/// unit-testable (its call site lives in a part file). /// 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);
});
}
+62
View File
@@ -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', () { group('ExternalIds.fromJellyfinProviderIds', () {
test('extracts Tmdb/Imdb/Tvdb (case-insensitive)', () { test('extracts Tmdb/Imdb/Tvdb (case-insensitive)', () {
final ids = ExternalIds.fromJellyfinProviderIds({'Tmdb': '12345', 'Imdb': 'tt99999', 'Tvdb': '777'}); final ids = ExternalIds.fromJellyfinProviderIds({'Tmdb': '12345', 'Imdb': 'tt99999', 'Tvdb': '777'});