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
+12 -2
View File
@@ -3,6 +3,7 @@ import '../media/ids.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_item_merge.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_server_client.dart';
@@ -594,6 +595,15 @@ class DataAggregationService {
/// 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.
///
/// Every server contributes every copy it holds, not one apiece: the same
/// movie routinely sits in a 4K library and an HD library on one server
/// (#1754). Results are deduped by global key and ordered best-first with
/// [compareLibraryCopies] so the chooser is stable across repeated passes.
///
/// Because per-server failures are dropped here, a caller holding earlier
/// results must merge rather than replace (see [mergeLibraryCopies]) — a
/// degraded wave is not evidence that a copy went away.
Future<List<MediaItem>> findByExternalIdsAcrossServers(
ExternalIds ids, {
required MediaKind kind,
@@ -618,11 +628,11 @@ class DataAggregationService {
);
} catch (e, st) {
appLogger.w('External-id lookup failed on ${entry.key}', error: e, stackTrace: st);
return null;
return const <MediaItem>[];
}
});
return (await Future.wait(futures)).nonNulls.toList();
return mergeLibraryCopies(const [], (await Future.wait(futures)).expand((items) => items));
}
/// Group libraries by server (internal aggregation helper).
+32 -14
View File
@@ -1115,12 +1115,16 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
/// each candidate's inline `ProviderIds`. [plexGuid] is a Plex-only hint and
/// has no meaning in Jellyfin's provider-id model, so it is ignored.
///
/// Every id-verified candidate of the first matching title is returned, not
/// just the first: one movie can sit in both a 4K library and an HD library
/// as two separate items, and the caller shows the user each copy (#1754).
///
/// Jellyfin cannot report season ordering to a non-admin: on 10.11.10,
/// `/Library/VirtualFolders` returns 403 and Series items omit
/// `DisplayOrder`. Resolve against an unknown provider rather than guessing
/// from `ProviderIds`, so only seasons on which TVDB and TMDB agree are gated.
@override
Future<MediaItem?> findByExternalIds(
Future<List<MediaItem>> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -1133,7 +1137,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
MediaKind.show => 'Series',
_ => null,
};
if (itemType == null || !ids.hasAny || titles.isEmpty) return null;
if (itemType == null || !ids.hasAny || titles.isEmpty) return const [];
final seasonIndex = season?.agreedSeason;
final shouldGateSeason = kind == MediaKind.show && seasonIndex != null && seasonIndex > 1;
@@ -1155,20 +1159,34 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'years': ?years,
...jellyfinImageQueryParameters,
});
final match = ExternalIds.jellyfinCandidateMatching(candidates, ids);
if (match == null) continue;
final item = _mapItems([match]).firstOrNull;
if (item == null) continue;
final matches = _mapItems(ExternalIds.jellyfinCandidatesMatching(candidates, ids));
if (matches.isEmpty) continue;
if (shouldGateSeason) {
final children = await fetchChildren(item.id);
if (!children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)) {
return null;
}
}
return _withLibraryFromAncestors(item);
final kept = shouldGateSeason ? await _keepMatchesWithSeason(matches, seasonIndex) : matches;
// A title that verified but has no season-gated survivor is a definitive
// "this server has the show, just not that season"; broader title forms
// would only reach other shows.
if (kept.isEmpty) return const [];
return Future.wait([for (final item in kept) _withLibraryFromAncestors(item)]);
}
return null;
return const [];
}
/// Keep only the series that actually have [seasonIndex]. One
/// `fetchChildren` per candidate, issued concurrently because the match list
/// is deliberately never truncated (see
/// [MediaServerClient.findByExternalIds]).
Future<List<MediaItem>> _keepMatchesWithSeason(List<MediaItem> items, int seasonIndex) async {
final kept = await Future.wait([
for (final item in items)
fetchChildren(
item.id,
).then((children) => children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)),
]);
return [
for (var index = 0; index < items.length; index++)
if (kept[index]) items[index],
];
}
/// Best-effort library stamp for items found outside a library context
+123 -78
View File
@@ -844,6 +844,16 @@ class PlexClient
return null;
}
/// Every raw `Metadata` entry of a container, for callers that must inspect
/// each sibling rather than assume the first is the only one (the
/// external-id reverse lookup: `/library/all` is server-wide, so one movie
/// held by two libraries answers with two entries).
List<Map<String, dynamic>> _getMetadataJsonList(MediaServerResponse response) {
final metadata = _getMediaContainer(response)?['Metadata'];
if (metadata is! List) return const [];
return metadata.whereType<Map<String, dynamic>>().toList();
}
List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
final container = _getMediaContainer(response);
if (container != null && container['Directory'] != null) {
@@ -3832,38 +3842,67 @@ class PlexClient
return ExternalIds.fromGuids(guids);
}
/// Drop a sequel match when the server does not actually have that season.
/// Map id-verified candidates to items, dropping any sequel the server does
/// not actually have that season of.
///
/// Only an [ExternalSeasonRef.agreedSeason] is gated on. Which provider a
/// library numbers its seasons by is a server-side setting no dataset can
/// supply, and reading it costs two extra requests per lookup, so a
/// disagreeing ref is left ungated rather than gated on a guess.
Future<MediaItem?> _applyExternalIdSeasonGate(
Map<String, dynamic> metadata, {
///
/// Gating costs one `fetchChildren` per show candidate. The candidate list
/// is deliberately never truncated (see
/// [MediaServerClient.findByExternalIds]), so the gates run concurrently
/// rather than letting latency grow with the number of library copies.
Future<List<MediaItem>> _gateExternalIdMatches(
Iterable<Map<String, dynamic>> candidates, {
required MediaKind kind,
required ExternalSeasonRef? season,
}) async {
final item = PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata));
if (kind != MediaKind.show) return item;
final entries = [
for (final metadata in candidates)
(metadata: metadata, item: PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata))),
];
final seasonIndex = season?.agreedSeason;
if (seasonIndex == null || seasonIndex <= 1) return item;
if (kind != MediaKind.show || seasonIndex == null || seasonIndex <= 1) {
return [for (final entry in entries) entry.item];
}
final ratingKey = metadata['ratingKey']?.toString();
// Cannot ask the question => do not gate.
if (ratingKey == null || ratingKey.isEmpty) return item;
final children = await fetchChildren(ratingKey);
return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex) ? item : null;
final kept = await Future.wait([for (final entry in entries) _hasSeason(entry.metadata, seasonIndex)]);
return [
for (var index = 0; index < entries.length; index++)
if (kept[index]) entries[index].item,
];
}
/// 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), so external ids in modern `Guid` arrays are
/// verified after title search. A resolved primary [plexGuid] can use the
/// exact server-side filter directly.
Future<bool> _hasSeason(Map<String, dynamic> metadata, int seasonIndex) async {
final ratingKey = metadata['ratingKey']?.toString();
// Cannot ask the question => do not gate.
if (ratingKey == null || ratingKey.isEmpty) return true;
final children = await fetchChildren(ratingKey);
return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex);
}
/// Server-wide external-id reverse lookup. Plex's `guid=` field filter
/// matches only the item's primary `plex://` guid (verified against PMS
/// 1.43), so a resolved [plexGuid] uses that exact filter while ids in
/// modern `Guid` arrays are verified client-side after a title search.
///
/// `/library/all` is server-wide — it is not scoped to a section — so a
/// movie held by both a 4K and an HD library answers as two sibling
/// `Metadata` entries, each carrying its own `librarySectionID`. Every
/// id-verified entry is kept (#1754).
///
/// An exact-guid hit does not short-circuit the title ladder: a library
/// still on a legacy agent carries `com.plexapp.agents.*` as its primary
/// guid, so that copy is invisible to the `guid=` filter and only the
/// id-verified title search finds it. Likewise the year-filtered page can
/// surface a copy the unfiltered page cut off at the container size, so
/// both contribute. The extra requests are spent once per uncached lookup,
/// off the render path and memoized for the session by
/// `CatalogLibraryMatcher`.
@override
Future<MediaItem?> findByExternalIds(
Future<List<MediaItem>> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -3876,82 +3915,88 @@ class PlexClient
MediaKind.show => 2,
_ => null,
};
if (plexType == null) return null;
if (!ids.hasAny && plexGuid == null) return null;
if (titles.isEmpty && plexGuid == null) return null;
if (plexType == null) return const [];
if (!ids.hasAny && plexGuid == null) return const [];
if (titles.isEmpty && plexGuid == null) return const [];
// Rating-key keyed so the guid filter and the title ladder can both
// contribute without doubling a copy they agree on. Kept in three buckets
// because the result order is exact-guid hits, then modern `Guid`
// matches, then legacy-agent ones.
final exact = <String, Map<String, dynamic>>{};
final modern = <String, Map<String, dynamic>>{};
final legacy = <String, Map<String, dynamic>>{};
void collect(Map<String, Map<String, dynamic>> into, Map<String, dynamic> item) {
final ratingKey = item['ratingKey']?.toString();
if (ratingKey == null || ratingKey.isEmpty) return;
into.putIfAbsent(ratingKey, () => item);
}
if (plexGuid != null) {
final response = await _getWithFailover(
'/library/all',
queryParameters: {'guid': plexGuid, 'type': plexType, 'includeGuids': 1},
);
final metadata = _getFirstMetadataJson(response);
if (metadata != null) {
return _applyExternalIdSeasonGate(metadata, kind: kind, season: season);
for (final item in _getMetadataJsonList(response)) {
collect(exact, item);
}
}
// Title attempts confirm candidates by external-id intersection, so
// without external ids they cannot match anything — stop at the exact
// guid lookup instead of burning requests that always come back empty.
if (!ids.hasAny) return null;
Future<({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})> attempt(
String title, {
required int size,
String? years,
}) async {
final response = await _getWithFailover(
'/library/all',
queryParameters: {
'title': title,
'type': plexType,
'includeGuids': 1,
'X-Plex-Container-Size': size,
'year': ?years,
},
);
final container = _getMediaContainer(response);
final metadata = container?['Metadata'];
if (metadata is! List) return (modern: null, legacy: null);
Map<String, dynamic>? legacy;
for (final item in metadata) {
if (item is! Map<String, dynamic>) continue;
final guids = item['Guid'];
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 (modern: null, legacy: legacy);
}
// Not `resolve(null)`: when the two providers disagree the season number is
// unresolvable but the entry is still a sequel, and the ±1 window around a
// sequel's own year excludes the parent show (its year is season one's).
final skipYearWindow = season?.isSequel ?? false;
for (var index = 0; index < titles.length; index++) {
final title = titles[index];
final size = index == 0 ? 20 : 50;
({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})? filtered;
if (index == 0 && year != null && !skipYearWindow) {
filtered = await attempt(title, size: size, years: '${year - 1},$year,${year + 1}');
final modern = filtered.modern;
if (modern != null) {
return _applyExternalIdSeasonGate(modern, kind: kind, season: season);
if (ids.hasAny) {
Future<bool> attempt(String title, {required int size, String? years}) async {
final response = await _getWithFailover(
'/library/all',
queryParameters: {
'title': title,
'type': plexType,
'includeGuids': 1,
'X-Plex-Container-Size': size,
'year': ?years,
},
);
var matched = false;
for (final item in _getMetadataJsonList(response)) {
final guids = item['Guid'];
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
collect(modern, item);
matched = true;
} else if (ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
collect(legacy, item);
matched = true;
}
}
return matched;
}
final unfiltered = await attempt(title, size: size);
final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy;
if (match != null) {
return _applyExternalIdSeasonGate(match, kind: kind, season: season);
// Not `resolve(null)`: when the two providers disagree the season number is
// unresolvable but the entry is still a sequel, and the ±1 window around a
// sequel's own year excludes the parent show (its year is season one's).
final skipYearWindow = season?.isSequel ?? false;
for (var index = 0; index < titles.length; index++) {
final title = titles[index];
final size = index == 0 ? 20 : 50;
final filteredMatched = index == 0 && year != null && !skipYearWindow
? await attempt(title, size: size, years: '${year - 1},$year,${year + 1}')
: false;
final unfilteredMatched = await attempt(title, size: size);
// The ladder exists to widen a title that matched nothing; once a
// title has produced copies, broader forms would only add other shows.
if (filteredMatched || unfilteredMatched) break;
}
}
return null;
final ordered = <String, Map<String, dynamic>>{...exact};
for (final bucket in [modern, legacy]) {
for (final entry in bucket.entries) {
ordered.putIfAbsent(entry.key, () => entry.value);
}
}
if (ordered.isEmpty) return const [];
return _gateExternalIdMatches(ordered.values, kind: kind, season: season);
}
@override