Two defects sank Explore's Plex integration. Discover started rejecting X-Plex-Container-Size=500 with a 400, so the watchlist membership snapshot never loaded: hearts stayed unknown and toggles dead. The snapshot now pages at 100, and getWatchlist refetches a rejected page in chunks of the row fetch's field-proven 25, so the next cap drift degrades gracefully instead of failing and callers' offset math survives either way. Worse, every Plex catalog item reached the library matcher carrying only its Discover rating key: listings were fetched without includeGuids, so the lookup rested entirely on exact plex:// guid equality between two metadata universes (Discover duplicate entries break it, notoriously for anime), and the title fallback can never confirm a candidate without external ids to intersect - "Not in your library" for owned titles the MAL provider matched fine. Discover listings now request Guids, the detail screen re-runs the matcher when enrichment gains id forms (generation-guarded so the slower bare lookup cannot overwrite the richer verdict), the matcher keys its memo by id fingerprint so the poor form's cached negative cannot answer for the rich one, and the Plex client stops burning title requests that external-id verification is guaranteed to reject. Discover requests are now logged like every other API surface; this bug shipped blind because they were not. close #1715
75 lines
3.5 KiB
Dart
75 lines
3.5 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../media/media_item.dart';
|
|
import '../../media/media_kind.dart';
|
|
import '../../models/catalog/catalog_item.dart';
|
|
import '../../providers/multi_server_provider.dart';
|
|
import '../../utils/title_match_candidates.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 DateTime Function() _now;
|
|
final Map<String, ({DateTime at, List<MediaItem> items})> _cache = {};
|
|
|
|
CatalogLibraryMatcher(this._multiServer) : _now = DateTime.now;
|
|
|
|
@visibleForTesting
|
|
CatalogLibraryMatcher.withClock(this._multiServer, this._now);
|
|
|
|
Future<List<MediaItem>> match(CatalogItem item) async {
|
|
if (!item.ids.hasAny) return const [];
|
|
// Do not use `identityKey`: its canonical series ids make every MAL/AniList
|
|
// season collide. All five Mushoku Tensei entries (`mal39535 s1`,
|
|
// `mal45576 s1`, `mal51179 s2`, `mal55888 s2`, `mal59193 s3`) collapse to
|
|
// `imdb:tt13293588`, so the first season-gated result would poison the rest.
|
|
// Namespace by source too: MAL and AniList can share a MAL id while
|
|
// contributing different localized title candidates. The id forms join
|
|
// the key because a detail load can enrich an item with external ids its
|
|
// row form lacked (#1715: Plex rows carry only a rating key); the richer
|
|
// lookup must not be short-circuited by the poorer form's cached
|
|
// negative.
|
|
final key = '${item.source.name}/${item.entryIdentityKey}/${item.ids.allKeys.join(',')}';
|
|
final cached = _cache[key];
|
|
if (cached != null && (cached.items.isNotEmpty || _now().difference(cached.at) < negativeTtl)) {
|
|
return cached.items;
|
|
}
|
|
|
|
// A sequel entry's year is its own season's, not the parent show's, so a
|
|
// ±1 window around it excludes the very show we are looking for. Fribb
|
|
// does not map a season for every entry, so fall back to the title: a
|
|
// strippable season suffix says "sequel" just as reliably. Dropping the
|
|
// year here also keeps the lookup at two requests, because the client no
|
|
// longer spends one on a year-filtered attempt that cannot match.
|
|
final isSequel = (item.season?.isSequel ?? false) || stripSeasonSuffix(item.title) != null;
|
|
final matches = await _multiServer.aggregationService.findByExternalIdsAcrossServers(
|
|
item.ids.toExternalIds(),
|
|
kind: item.kind,
|
|
titles: titleMatchCandidates([item.title, ...item.altTitles]),
|
|
year: isSequel ? null : item.year,
|
|
plexGuid: _plexGuidFor(item),
|
|
season: item.season,
|
|
);
|
|
_cache[key] = (at: _now(), items: matches);
|
|
return matches;
|
|
}
|
|
|
|
/// The exact `plex://` guid for a Plex Discover item, which its own rating
|
|
/// key already is. Free — no request, no cloud lookup; other sources get
|
|
/// null and fall back to the title candidates.
|
|
String? _plexGuidFor(CatalogItem item) {
|
|
final plexId = item.ids.plex;
|
|
if (item.source != CatalogSourceId.plex || plexId == null || plexId.isEmpty) return null;
|
|
return 'plex://${item.kind == MediaKind.movie ? 'movie' : 'show'}/$plexId';
|
|
}
|
|
}
|