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:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user