From 9d812fa3cc240020c3da0f4ed90df92743161989 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:42:50 +0200 Subject: [PATCH] fix(plex): match legacy agent GUIDs closing #1566 --- lib/media/media_server_client.dart | 13 +- lib/services/plex_client.dart | 35 +-- lib/utils/external_ids.dart | 57 ++++- .../plex_external_id_lookup_test.dart | 205 ++++++++++++++++++ test/utils/external_ids_test.dart | 62 ++++++ 5 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 test/services/plex_external_id_lookup_test.dart diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 297a0e7b..12069d19 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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 findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}); /// Chapters and intro/credits markers for [itemId]. Plex returns both in one diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index bdb96708..7c456f29 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 attempt(String? years) async { + Future<({Map? modern, Map? 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? legacy; for (final item in metadata) { if (item is! Map) 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? modern, Map? 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 diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart index 7660421d..319c1d40 100644 --- a/lib/utils/external_ids.dart +++ b/lib/utils/external_ids.dart @@ -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). diff --git a/test/services/plex_external_id_lookup_test.dart b/test/services/plex_external_id_lookup_test.dart new file mode 100644 index 00000000..b6dff21c --- /dev/null +++ b/test/services/plex_external_id_lookup_test.dart @@ -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 = []; + 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); + }); +} diff --git a/test/utils/external_ids_test.dart b/test/utils/external_ids_test.dart index 8b726728..6b18c44a 100644 --- a/test/utils/external_ids_test.dart +++ b/test/utils/external_ids_test.dart @@ -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 = [ + 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'});