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:
@@ -1,5 +1,6 @@
|
||||
import 'ids.dart';
|
||||
import 'media_item.dart';
|
||||
import 'media_version.dart';
|
||||
|
||||
/// Merge freshly fetched metadata with identity and library context already
|
||||
/// known by the caller. The fetched item owns descriptive fields, while
|
||||
@@ -12,3 +13,63 @@ MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId f
|
||||
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tallest version height an item exposes, or 0 when the backend reported
|
||||
/// none.
|
||||
int _bestResolutionHeight(MediaItem item) {
|
||||
var best = 0;
|
||||
for (final version in item.mediaVersions ?? const <MediaVersion>[]) {
|
||||
final height = version.resolutionHeight;
|
||||
if (height != null && height > best) best = height;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Order the library copies of one title best-first: highest resolution, then
|
||||
/// library title, then server name, then global key.
|
||||
///
|
||||
/// Total and derived purely from the items, so a chooser can re-sort after
|
||||
/// merging a later resolution pass without its rows jumping around.
|
||||
int compareLibraryCopies(MediaItem a, MediaItem b) {
|
||||
final byResolution = _bestResolutionHeight(b).compareTo(_bestResolutionHeight(a));
|
||||
if (byResolution != 0) return byResolution;
|
||||
final byLibrary = (a.libraryTitle ?? '').compareTo(b.libraryTitle ?? '');
|
||||
if (byLibrary != 0) return byLibrary;
|
||||
final byServer = (a.serverName ?? '').compareTo(b.serverName ?? '');
|
||||
if (byServer != 0) return byServer;
|
||||
return a.globalKey.compareTo(b.globalKey);
|
||||
}
|
||||
|
||||
/// Fold a re-resolved copy into the one already on screen.
|
||||
///
|
||||
/// The addition is fresher, but a degraded pass must not erase context. The
|
||||
/// Jellyfin library stamp is a best-effort `/Items/{id}/Ancestors` call that
|
||||
/// hands back an unstamped item when it fails, and a copy that lost its
|
||||
/// library title is indistinguishable from its sibling in the same server's
|
||||
/// other library — exactly the ambiguity the chooser exists to resolve. The
|
||||
/// version list behind the resolution hint is treated the same way.
|
||||
MediaItem _mergeCopy(MediaItem existing, MediaItem addition) {
|
||||
final versions = addition.mediaVersions;
|
||||
return addition.copyWith(
|
||||
libraryId: addition.libraryId ?? existing.libraryId,
|
||||
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
|
||||
serverName: addition.serverName ?? existing.serverName,
|
||||
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
|
||||
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
|
||||
///
|
||||
/// Never removes a copy, and never downgrades one. The cross-server fan-out
|
||||
/// behind these lists logs and skips per-server failures, so a later pass can
|
||||
/// legitimately come back short a server, or short the best-effort library
|
||||
/// stamp of a copy it did return.
|
||||
List<MediaItem> mergeLibraryCopies(Iterable<MediaItem> current, Iterable<MediaItem> additions) {
|
||||
final byKey = <String, MediaItem>{for (final item in current) item.globalKey: item};
|
||||
for (final item in additions) {
|
||||
final existing = byKey[item.globalKey];
|
||||
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
|
||||
}
|
||||
return byKey.values.toList()..sort(compareLibraryCopies);
|
||||
}
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -68,6 +68,25 @@ class MediaVersion {
|
||||
/// them when metadata is fetched with `checkFiles=1`.
|
||||
bool get isPlayable => parts.isEmpty || parts.any((part) => part.isPlayable);
|
||||
|
||||
/// Approximate vertical resolution, for ordering versions best-first.
|
||||
///
|
||||
/// Plex reports either a numeric height (`"1080"`) or a named tier
|
||||
/// (`"sd"`, `"4k"`) and usually both; Jellyfin reports [height] directly.
|
||||
/// Null when the backend gave neither.
|
||||
int? get resolutionHeight {
|
||||
final reported = height;
|
||||
if (reported != null && reported > 0) return reported;
|
||||
final named = (videoResolution ?? '').trim().toLowerCase();
|
||||
return switch (named) {
|
||||
'' => null,
|
||||
'sd' => 480,
|
||||
'hd' => 720,
|
||||
'4k' => 2160,
|
||||
'8k' => 4320,
|
||||
_ => int.tryParse(named),
|
||||
};
|
||||
}
|
||||
|
||||
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)".
|
||||
/// When [name] is set, it prefixes the technical label so a user can tell
|
||||
/// "Director's Cut · 1080p H.264 MKV" apart from "Theatrical Cut · 1080p
|
||||
|
||||
Reference in New Issue
Block a user