fix(explore): list every library copy of a title, not one per server

`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore
"In these libraries" chooser could never show more than one copy per server.
A movie held by both a 4K library and an HD library on one Plex server
therefore resolved to whichever copy came back first, with no way to reach
the other.

Return every id-verified match instead. `/library/all` is already
server-wide and each `Metadata` entry carries its own `librarySectionID`,
so both copies come back labelled with no extra request; Plex was simply
taking `Metadata[0]` and the title ladder was returning on its first hit.
An exact-guid hit no longer short-circuits the title search either — a
library still on a legacy agent has a different primary guid and is
invisible to the `guid=` filter.

Copies are deduped by global key and ordered best-first, and each row now
states its resolution, since library names need not mention it.

Resolution passes merge rather than replace: the cross-server fan-out logs
and skips per-server failures, so a later pass can come back short a server
that answered an earlier one, and a failed pass no longer claims the title
left the library. Duplicate keys fold field by field, because Jellyfin's
library stamp is a best-effort ancestors lookup that returns the item bare
when it fails and an unstamped row is indistinguishable from its sibling.
Focus nodes are keyed by copy and reclaimed after a merge re-sorts the
rows, so a dpad user is not thrown to a different copy.

close #1754
This commit is contained in:
edde746
2026-08-02 06:40:04 +02:00
parent f78f65faf5
commit bc0d14a749
13 changed files with 916 additions and 191 deletions
+20 -12
View File
@@ -504,7 +504,7 @@ abstract class MediaServerClient {
/// has no external mapping for the item.
Future<ExternalIds> fetchExternalIds(String itemId);
/// Reverse lookup: find a library movie/show matching any of [ids].
/// Reverse lookup: find every library movie/show matching any of [ids].
///
/// Neither backend can filter by external id — Plex's `guid=` matches only
/// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped
@@ -512,7 +512,16 @@ abstract class MediaServerClient {
/// title and verify candidates against their exact external ids. Title
/// alone never produces a match.
///
/// [titles] are tried in order until one yields an id-verified candidate;
/// One title can own several library items: a server with a 4K section and
/// an HD section holds two rating keys for the same movie, and a library
/// still on a legacy agent carries a different primary guid than its modern
/// sibling. Implementations MUST return every id-verified copy rather than
/// the first, and MUST NOT truncate — external-id verification is the only
/// bound, so a long list means the user genuinely owns that many copies and
/// the caller (the Explore "In these libraries" chooser) exists to show
/// them. Ordering is the implementation's, and callers re-sort.
///
/// [titles] are tried in order until one yields id-verified candidates;
/// pass the entry's own title first and broader forms after (see
/// `titleMatchCandidates`). A sequel entry's own title never matches its
/// parent show, which is why more than one is needed. [year] applies a ±1
@@ -520,22 +529,21 @@ abstract class MediaServerClient {
/// season's, not the show's.
///
/// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller
/// already holds, which the local server *can* filter on exactly, skipping
/// the title search. It is never resolved over the network — only Plex
/// Discover catalog items carry one, in their own rating key. Other backends
/// ignore it.
/// already holds, which the local server *can* filter on exactly. It is
/// never resolved over the network — only Plex Discover catalog items carry
/// one, in their own rating key. Other backends ignore it.
///
/// [season] gates the result: when the entry maps to season 2+ of a longer
/// series, a match is only returned if the server actually has that season.
/// [season] gates the results: when the entry maps to season 2+ of a longer
/// series, a candidate is only kept if the server actually has that season.
/// Implementations MUST gate only on [ExternalSeasonRef.agreedSeason] — the
/// provider a library numbers its seasons by is a server-side setting no
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
/// on a guess.
///
/// 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(
/// Returns an empty list 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<List<MediaItem>> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],