Files
plezy/test/services/system_shelf_service_test.dart
T
edde746 291a22a4a4 feat(tvos): fetch Top Shelf content live and show poster art
The Top Shelf extension now fetches Continue Watching directly from
Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its
last foreground Discover pass. The app publishes per-profile server
descriptors on every shelf sync (`updateSources`): token-free metadata
in the app group, tokens in an app-group-shared keychain item, both
wiped by `clear`. On success the extension rewrites the cached payload
as the offline fallback; any fetch failure falls back to the previous
cache-replay behavior. Poster images are passed as remote URLs, so the
extension no longer depends on app-side artwork downloads.

Episodes now render season/series poster art (2:3, `.poster` shape)
instead of 16:9 episode stills, and labels lead with the S/E marker so
long titles no longer hide it behind the focused-item marquee. Shelf
schema v3 (Dart, Android, tvOS envelopes bumped together) discards
stale wide-art caches instead of letterboxing them into poster slots.

close #1474
close #1835
2026-08-09 10:59:16 +02:00

375 lines
14 KiB
Dart

import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.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_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/system_shelf_service.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
class _ShelfClient implements MediaServerClient {
_ShelfClient({this.throwOnThumbnail = false});
final bool throwOnThumbnail;
final List<({String? path, int? width, int? height})> thumbnailRequests = [];
@override
ServerId get serverId => ServerId('server-a');
@override
String get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) {
if (throwOnThumbnail) throw StateError('conversion failed');
thumbnailRequests.add((path: path, width: width, height: height));
return 'https://media.invalid$path?token=transient';
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/system_shelf');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
// The real Plex/Jellyfin client fixtures used by the updateSources tests
// construct their API caches at creation time.
setUpAll(() {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
addTearDown(db.close);
});
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('live shelf tap acknowledges only an attached consumer', () async {
final service = SystemShelfService.forTesting(channel: channel);
const call = MethodCall('onShelfItemTap', {'contentId': 'server:item'});
expect(await service.handleMethodCallForTesting(call), isFalse);
final received = <String>[];
service.onShelfItemTap = received.add;
expect(await service.handleMethodCallForTesting(call), isTrue);
expect(received, ['server:item']);
});
test('delayed support result is dropped after synchronous owner invalidation', () async {
final support = Completer<bool>();
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () => support.future);
service.beginProfileSession('owner-a');
final delayed = service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient());
final ended = service.endProfileSession('owner-a');
support.complete(true);
expect(await delayed, isFalse);
await ended;
expect(calls.map((call) => call.method), ['clear']);
expect(calls.single.arguments, {
'schemaVersion': SystemShelfService.schemaVersion,
'ownerId': 'owner-a',
'generation': 2,
});
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse);
});
test('dispatched old sync settles before clear and new owner sync', () async {
final syncDispatched = Completer<void>();
final releaseOldSync = Completer<void>();
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (call.method == 'sync' && !syncDispatched.isCompleted) {
syncDispatched.complete();
await releaseOldSync.future;
}
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
service.beginProfileSession('owner-a');
final oldSync = service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient());
await syncDispatched.future;
final oldEnd = service.endProfileSession('owner-a');
service.beginProfileSession('owner-b');
final newSync = service.syncFromContinueWatching('owner-b', const [], (_) => _ShelfClient());
await Future<void>.delayed(Duration.zero);
expect(calls.map((call) => call.method), ['sync']);
releaseOldSync.complete();
expect(await oldSync, isTrue);
await oldEnd;
expect(await newSync, isTrue);
expect(calls.map((call) => call.method), ['sync', 'clear', 'sync']);
expect((calls.last.arguments as Map)['ownerId'], 'owner-b');
});
test('native failure is contained and the ordered tail accepts a later sync', () async {
var syncCount = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'sync' && syncCount++ == 0) {
throw PlatformException(code: 'first-failed');
}
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
service.beginProfileSession('owner-a');
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse);
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isTrue);
expect(syncCount, 2);
});
test('versioned payload carries transient source only and conversion failure keeps metadata', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
service.beginProfileSession('owner-a');
final item = testMediaItem(
id: 'item-a',
backend: MediaBackend.plex,
title: 'Private title',
summary: 'Private summary',
thumbPath: '/poster',
serverId: 'server-a',
serverName: 'Server',
);
final client = _ShelfClient();
expect(await service.syncFromContinueWatching('owner-a', [item], (_) => client), isTrue);
final envelope = calls.single.arguments as Map;
expect(envelope['schemaVersion'], 3);
expect(envelope['schemaVersion'], SystemShelfService.schemaVersion);
expect(envelope['ownerId'], 'owner-a');
final sent = (envelope['items'] as List).single as Map;
expect(sent['posterSourceUri'], startsWith('https://media.invalid/'));
expect(sent, isNot(contains('posterUri')));
expect(client.thumbnailRequests.single, (path: '/poster', width: 640, height: 360));
calls.clear();
expect(
await service.syncFromContinueWatching('owner-a', [item], (_) => _ShelfClient(throwOnThumbnail: true)),
isTrue,
);
final fallback = (((calls.single.arguments as Map)['items'] as List).single as Map);
expect(fallback['title'], 'Private title');
expect(fallback['posterSourceUri'], isNull);
});
test('unsupported integration completes without native mutation', () async {
var nativeCalls = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
nativeCalls++;
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => false);
service.beginProfileSession('owner-a');
expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse);
expect(nativeCalls, 0);
});
MediaItem shelfEpisode({String? parentThumbPath, String? grandparentThumbPath}) => testMediaItem(
id: 'ep-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Finale',
grandparentTitle: 'Show',
parentThumbPath: parentThumbPath,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: '/spoiler-safe-art',
thumbPath: '/episode-still',
serverId: 'server-a',
serverName: 'Server',
);
test('tvOS target walks the season/series/own poster chain at 600x900 and skips the spoiler override', () async {
messenger.setMockMethodCallHandler(channel, (call) async => true);
final client = _ShelfClient();
final service = SystemShelfService.forTesting(
channel: channel,
isSupported: () async => true,
isTvosTarget: () => true,
);
service.beginProfileSession('owner-a');
expect(
await service.syncFromContinueWatching(
'owner-a',
[
shelfEpisode(parentThumbPath: '/season-poster', grandparentThumbPath: '/series-poster'),
shelfEpisode(grandparentThumbPath: '/series-poster'),
shelfEpisode(),
],
(_) => client,
// Unwatched episodes would take the spoiler-safe art on Android;
// posters cannot spoil, so tvOS must ignore the flag entirely.
hideSpoilers: true,
),
isTrue,
);
expect(client.thumbnailRequests.map((request) => request.path), [
'/season-poster',
'/series-poster',
'/episode-still',
]);
for (final request in client.thumbnailRequests) {
expect((request.width, request.height), (600, 900));
}
});
test('non-tvOS target keeps 640x360 episode thumbnails and the spoiler-safe override', () async {
messenger.setMockMethodCallHandler(channel, (call) async => true);
final client = _ShelfClient();
final service = SystemShelfService.forTesting(
channel: channel,
isSupported: () async => true,
isTvosTarget: () => false,
);
service.beginProfileSession('owner-a');
final episode = shelfEpisode(parentThumbPath: '/season-poster', grandparentThumbPath: '/series-poster');
expect(await service.syncFromContinueWatching('owner-a', [episode], (_) => client, hideSpoilers: true), isTrue);
expect(await service.syncFromContinueWatching('owner-a', [episode], (_) => client), isTrue);
expect(client.thumbnailRequests.map((request) => request.path), ['/spoiler-safe-art', '/episode-still']);
for (final request in client.thumbnailRequests) {
expect((request.width, request.height), (640, 360));
}
});
test('updateSources publishes per-kind descriptors and skips unusable clients', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isTvosTarget: () => true);
service.beginProfileSession('owner-a');
final plex = testPlexClient(
serverId: ServerId('plex-1'),
serverName: 'Plex Server',
baseUrl: 'https://plex.example.com',
token: 'plex-token',
);
final jellyfin = testJellyfinClient(
connection: testJellyfinConnection(
machineId: 'jf-1',
userId: 'user-7',
serverName: 'Jellyfin Server',
baseUrl: 'https://jf.example.com',
accessToken: 'jf-token',
),
);
final emby = testEmbyClient(
connection: testEmbyConnection(machineId: 'emby-1', userId: 'emby-user', accessToken: 'emby-token'),
);
final tokenlessPlex = testPlexClient(serverId: ServerId('plex-2'), token: null);
final tokenlessJellyfin = testJellyfinClient(
connection: testJellyfinConnection(machineId: 'jf-2', accessToken: ''),
);
final unknownBackend = _ShelfClient();
expect(
await service.syncServerSources('owner-a', [
plex,
jellyfin,
emby,
tokenlessPlex,
tokenlessJellyfin,
unknownBackend,
]),
isTrue,
);
expect(calls.single.method, 'updateSources');
final envelope = calls.single.arguments as Map;
expect(envelope['schemaVersion'], 3);
expect(envelope['ownerId'], 'owner-a');
expect(envelope['generation'], 1);
expect(envelope['maxItems'], 20);
final servers = (envelope['servers'] as List).cast<Map>();
expect(servers, hasLength(3));
expect(servers[0], {
'serverId': 'plex-1',
'kind': 'plex',
'name': 'Plex Server',
'baseUrl': 'https://plex.example.com',
'token': 'plex-token',
});
expect(servers[1], {
'serverId': 'jf-1',
'kind': 'jellyfin',
'name': 'Jellyfin Server',
'baseUrl': 'https://jf.example.com',
'token': 'jf-token',
'userId': 'user-7',
});
expect(servers[2]['kind'], 'emby');
expect(servers[2]['userId'], 'emby-user');
expect(servers[2]['token'], 'emby-token');
});
test('syncServerSources is a no-op off tvOS', () async {
var nativeCalls = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
nativeCalls++;
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isTvosTarget: () => false);
service.beginProfileSession('owner-a');
expect(await service.syncServerSources('owner-a', [testPlexClient()]), isFalse);
expect(nativeCalls, 0);
});
test('sources built for an invalidated owner are never dispatched', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
final service = SystemShelfService.forTesting(channel: channel, isTvosTarget: () => true);
service.beginProfileSession('owner-a');
final pending = service.syncServerSources('owner-a', [testPlexClient()]);
final ended = service.endProfileSession('owner-a');
expect(await pending, isFalse);
await ended;
expect(calls.map((call) => call.method), ['clear']);
});
}