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
+66
View File
@@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_item_merge.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_version.dart';
import '../test_helpers/media_items.dart';
void main() {
@@ -47,4 +48,69 @@ void main() {
expect(merged.libraryId, 'old-lib');
expect(merged.libraryTitle, 'Old');
});
MediaItem copy({
required String id,
String? libraryTitle,
String? videoResolution,
List<MediaVersion>? mediaVersions,
}) => testMediaItem(
id: id,
serverId: 'server-1',
serverName: 'Living Room',
libraryId: libraryTitle == null ? null : '$id-lib',
libraryTitle: libraryTitle,
mediaVersions:
mediaVersions ??
(videoResolution == null ? null : [MediaVersion(id: '$id-v', videoResolution: videoResolution)]),
);
test('orders library copies by resolution, best first', () {
final merged = mergeLibraryCopies(const [], [
copy(id: 'hd', libraryTitle: 'Movies', videoResolution: '1080'),
copy(id: 'uhd', libraryTitle: '4K Movies', videoResolution: '4k'),
]);
expect(merged.map((item) => item.id), ['uhd', 'hd']);
});
test('keeps copies a later pass did not return', () {
final merged = mergeLibraryCopies([copy(id: 'hd', libraryTitle: 'Movies')], const []);
expect(merged.map((item) => item.id), ['hd']);
});
test('an unstamped re-resolve does not strip a copy of its library', () {
// Jellyfin's library stamp is a best-effort ancestors lookup that returns
// the item unstamped when it fails. Losing the title would make this copy
// indistinguishable from its sibling in the server's other library.
final merged = mergeLibraryCopies(
[copy(id: 'hd', libraryTitle: 'Movies', videoResolution: '1080')],
[copy(id: 'hd')],
);
expect(merged.single.libraryId, 'hd-lib');
expect(merged.single.libraryTitle, 'Movies');
expect(merged.single.serverName, 'Living Room');
expect(merged.single.mediaVersions?.single.videoResolution, '1080');
});
test('a re-resolve carrying fresher library context wins', () {
final merged = mergeLibraryCopies(
[copy(id: 'hd', libraryTitle: 'Movies', videoResolution: '1080')],
[copy(id: 'hd', libraryTitle: 'Renamed Movies', videoResolution: '4k')],
);
expect(merged.single.libraryTitle, 'Renamed Movies');
expect(merged.single.mediaVersions?.single.videoResolution, '4k');
});
test('an empty version list is treated as absent, not as a downgrade', () {
final merged = mergeLibraryCopies(
[copy(id: 'hd', libraryTitle: 'Movies', videoResolution: '1080')],
[copy(id: 'hd', libraryTitle: 'Movies', mediaVersions: const [])],
);
expect(merged.single.mediaVersions?.single.videoResolution, '1080');
});
}