`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
117 lines
4.2 KiB
Dart
117 lines
4.2 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/media/ids.dart';
|
|
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() {
|
|
MediaItem item({String? serverId, String? serverName, String? libraryId, String? libraryTitle}) => testMediaItem(
|
|
id: 'item',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
libraryId: libraryId,
|
|
libraryTitle: libraryTitle,
|
|
);
|
|
|
|
test('uses the authoritative fallback when both items omit server identity', () {
|
|
final merged = mergeFetchedMediaItem(fetched: item(), fallbackServerId: ServerId('fallback'));
|
|
|
|
expect(merged.serverId, 'fallback');
|
|
expect(merged.globalKey, 'fallback:item');
|
|
});
|
|
|
|
test('preserves existing identity while preferring fetched library context', () {
|
|
final merged = mergeFetchedMediaItem(
|
|
fetched: item(serverId: 'fetched', serverName: 'Fetched', libraryId: 'new-lib', libraryTitle: 'New'),
|
|
existing: item(serverId: 'existing', serverName: 'Existing', libraryId: 'old-lib', libraryTitle: 'Old'),
|
|
fallbackServerId: ServerId('fallback'),
|
|
);
|
|
|
|
expect(merged.serverId, 'existing');
|
|
expect(merged.serverName, 'Existing');
|
|
expect(merged.libraryId, 'new-lib');
|
|
expect(merged.libraryTitle, 'New');
|
|
});
|
|
|
|
test('fills missing fetched library context from the existing item', () {
|
|
final merged = mergeFetchedMediaItem(
|
|
fetched: item(),
|
|
existing: item(libraryId: 'old-lib', libraryTitle: 'Old'),
|
|
fallbackServerId: ServerId('fallback'),
|
|
);
|
|
|
|
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');
|
|
});
|
|
}
|