diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index a51dbb3b..a04efacd 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -488,6 +488,17 @@ abstract class MediaServerClient { /// has no external mapping for the item. Future fetchExternalIds(String itemId); + /// 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. + Future findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}); + /// Chapters and intro/credits markers for [itemId]. Plex returns both in one /// round trip; Jellyfin combines item-level chapters with best-effort native /// media segments. Implementations may cache. diff --git a/lib/services/catalog/catalog_library_matcher.dart b/lib/services/catalog/catalog_library_matcher.dart new file mode 100644 index 00000000..be8e9538 --- /dev/null +++ b/lib/services/catalog/catalog_library_matcher.dart @@ -0,0 +1,37 @@ +import '../../media/media_item.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../providers/multi_server_provider.dart'; + +/// Matches external catalog items back to the user's libraries. +/// +/// One reverse-lookup fan-out per tap (see +/// `DataAggregationService.findByExternalIdsAcrossServers`), memoized for +/// the session: positive hits are kept (library membership rarely shrinks +/// mid-session), negatives expire so newly-added media is picked up. +/// Profile-scoped via the provider subtree, so a profile switch drops the +/// cache by construction. +class CatalogLibraryMatcher { + static const Duration negativeTtl = Duration(minutes: 10); + + final MultiServerProvider _multiServer; + final Map items})> _cache = {}; + + CatalogLibraryMatcher(this._multiServer); + + Future> match(CatalogItem item) async { + if (!item.ids.hasAny) return const []; + final key = item.identityKey; + final cached = _cache[key]; + if (cached != null && (cached.items.isNotEmpty || DateTime.now().difference(cached.at) < negativeTtl)) { + return cached.items; + } + final matches = await _multiServer.aggregationService.findByExternalIdsAcrossServers( + item.ids.toExternalIds(), + kind: item.kind, + title: item.title, + year: item.year, + ); + _cache[key] = (at: DateTime.now(), items: matches); + return matches; + } +} diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 6ac50f82..99456a41 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -537,6 +537,31 @@ class DataAggregationService { return result; } + /// Reverse external-id lookup fanned out to every online server (see + /// [MediaServerClient.findByExternalIds]). One request wave per tap on an + /// Explore catalog item; per-server failures are logged and skipped. + Future> findByExternalIdsAcrossServers( + ExternalIds ids, { + required MediaKind kind, + String? title, + int? year, + }) async { + if (!ids.hasAny) return []; + final clients = _serverManager.onlineClients; + if (clients.isEmpty) return []; + + final futures = clients.entries.map((entry) async { + try { + return await entry.value.findByExternalIds(ids, kind: kind, title: title, year: year); + } catch (e, st) { + appLogger.w('External-id lookup failed on ${entry.key}', error: e, stackTrace: st); + return null; + } + }); + + return (await Future.wait(futures)).nonNulls.toList(); + } + /// Group libraries by server (internal aggregation helper). Map> _groupLibrariesByServer(List libraries) { final grouped = >{}; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 17b807d1..c889c5c0 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1041,6 +1041,74 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _mapItems([...results.first, ...results[1]]); } + /// Jellyfin removed `anyProviderIdEquals`, so the reverse lookup is a + /// title search verified against each candidate's inline `ProviderIds` — + /// exact-id verification, so localized-title misses are possible but a + /// wrong item can never match. + /// + /// When [year] is known, a ±1 `years=` window (comma-OR, verified on JF + /// 10.11) is applied first so short/common titles keep the true match + /// inside the 20-item response; a second unfiltered attempt covers items + /// with missing or off-window year metadata. + @override + Future findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async { + final itemType = switch (kind) { + MediaKind.movie => 'Movie', + MediaKind.show => 'Series', + _ => null, + }; + if (itemType == null || !ids.hasAny || title == null || title.isEmpty) return null; + + Future attempt(String? years) async { + final candidates = await _fetchItemsArray('/Items', { + 'userId': connection.userId, + 'SearchTerm': title, + 'Recursive': 'true', + 'Limit': '20', + 'IncludeItemTypes': itemType, + 'Fields': 'ProviderIds,$_browseFields', + 'years': ?years, + ...jellyfinImageQueryParameters, + }); + final match = ExternalIds.jellyfinCandidateMatching(candidates, ids); + if (match == null) return null; + final item = _mapItems([match]).firstOrNull; + if (item == null) return null; + return _withLibraryFromAncestors(item); + } + + if (year != null) { + final match = await attempt('${year - 1},$year,${year + 1}'); + if (match != null) return match; + } + return attempt(null); + } + + /// Best-effort library stamp for items found outside a library context + /// (the search-based reverse lookup): `/Items/{id}/Ancestors` names the + /// owning CollectionFolder. One extra request per match (memoized with the + /// match by the session-level matcher cache); failures return the item + /// unstamped. + Future _withLibraryFromAncestors(MediaItem item) async { + try { + final response = await _http.get( + '/Items/${_segment(item.id)}/Ancestors', + queryParameters: {'userId': connection.userId}, + ); + throwIfHttpError(response); + final data = response.data; + if (data is! List) return item; + for (final ancestor in data.whereType>()) { + if (ancestor['Type'] == 'CollectionFolder') { + return item.copyWith(libraryId: ancestor['Id'] as String?, libraryTitle: ancestor['Name'] as String?); + } + } + } catch (e) { + appLogger.d('Jellyfin ancestors lookup failed for ${item.id}', error: e); + } + return item; + } + @override Future> fetchPersonMedia(String personId) async { final all = []; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ecbd5cf0..3b62fe78 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -4398,6 +4398,59 @@ class PlexClient return ExternalIds.fromGuids(guids); } + /// Server-wide title filter + client-side Guid-array 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. + /// + /// 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 + /// short/common titles keep the true match inside the 20-item response; + /// the year filter drops items with no year metadata, hence the + /// unfiltered second attempt. + @override + Future findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async { + final plexType = switch (kind) { + MediaKind.movie => 1, + MediaKind.show => 2, + _ => null, + }; + if (plexType == null || !ids.hasAny || title == null || title.isEmpty) return null; + + Future attempt(String? years) async { + final response = await _getWithFailover( + '/library/all', + queryParameters: { + 'title': title, + 'type': plexType, + 'includeGuids': 1, + 'X-Plex-Container-Size': 20, + 'year': ?years, + }, + ); + final container = _getMediaContainer(response); + final metadata = container?['Metadata']; + if (metadata is! List) return null; + 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)); + } + } + return null; + } + + if (year != null) { + final match = await attempt('${year - 1},$year,${year + 1}'); + if (match != null) return match; + } + return attempt(null); + } + @override Future reportPlaybackStarted({ required String itemId, diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart index 6b4359c5..7660421d 100644 --- a/lib/utils/external_ids.dart +++ b/lib/utils/external_ids.dart @@ -15,6 +15,14 @@ class ExternalIds { bool get hasAny => imdb != null || tmdb != null || tvdb != null; + /// True when any id form matches [other]. Used to verify reverse-lookup + /// candidates (never yields false positives; the two sides may carry + /// different id subsets). + bool intersects(ExternalIds other) => + (imdb != null && imdb == other.imdb) || + (tmdb != null && tmdb == other.tmdb) || + (tvdb != null && tvdb == other.tvdb); + factory ExternalIds.fromGuids(List guids) { String? imdb; int? tmdb; @@ -34,6 +42,19 @@ class ExternalIds { return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); } + /// 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). + static Map? jellyfinCandidateMatching(List> candidates, ExternalIds ids) { + for (final item in candidates) { + final providerIds = item['ProviderIds']; + if (providerIds is! Map) continue; + final candidate = ExternalIds.fromJellyfinProviderIds(providerIds.cast()); + if (ids.intersects(candidate)) return item; + } + return null; + } + /// Build from a Jellyfin `ProviderIds` map. Jellyfin stores external IDs /// directly on every `BaseItemDto` so no extra fetch is needed. /// Keys are case-insensitive in practice (`Tmdb`, `Imdb`, `Tvdb`). diff --git a/test/utils/external_ids_matching_test.dart b/test/utils/external_ids_matching_test.dart new file mode 100644 index 00000000..9bccc293 --- /dev/null +++ b/test/utils/external_ids_matching_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/external_ids.dart'; + +void main() { + group('ExternalIds.intersects with Plex Guid arrays', () { + test('verifies a raw Plex Guid array against target ids', () { + final candidate = ExternalIds.fromGuids(const [ + {'id': 'imdb://tt15398776'}, + {'id': 'tmdb://872585'}, + {'id': 'tvdb://287533'}, + ]); + expect(const ExternalIds(tmdb: 872585).intersects(candidate), isTrue); + expect(const ExternalIds(imdb: 'tt0000001').intersects(candidate), isFalse); + }); + }); + + group('ExternalIds.intersects', () { + test('matches when any shared id form is equal', () { + const trakt = ExternalIds(imdb: 'tt0133093', tmdb: 603); + expect(trakt.intersects(const ExternalIds(tmdb: 603)), isTrue); + expect(trakt.intersects(const ExternalIds(imdb: 'tt0133093', tvdb: 999)), isTrue); + }); + + test('never matches on absent or differing ids', () { + const trakt = ExternalIds(imdb: 'tt0133093'); + expect(trakt.intersects(const ExternalIds(tmdb: 603)), isFalse); + expect(trakt.intersects(const ExternalIds(imdb: 'tt9999999')), isFalse); + expect(const ExternalIds().intersects(const ExternalIds()), isFalse); + }); + }); + + group('ExternalIds.jellyfinCandidateMatching', () { + const target = ExternalIds(imdb: 'tt0133093', tmdb: 603); + + test('picks the candidate whose ProviderIds intersect, skipping others', () { + final candidates = >[ + { + 'Name': 'The Matrix Reloaded', + 'ProviderIds': {'Imdb': 'tt0234215', 'Tmdb': '604'}, + }, + {'Name': 'No provider ids'}, + { + 'Name': 'The Matrix', + 'ProviderIds': {'Tmdb': '603'}, + }, + ]; + expect(ExternalIds.jellyfinCandidateMatching(candidates, target)?['Name'], 'The Matrix'); + }); + + test('returns null when nothing verifies', () { + final candidates = >[ + { + 'Name': 'Similar title, different film', + 'ProviderIds': {'Imdb': 'tt0234215'}, + }, + ]; + expect(ExternalIds.jellyfinCandidateMatching(candidates, target), isNull); + }); + }); +}