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
This commit is contained in:
edde746
2026-08-09 10:59:16 +02:00
parent 0b4fd9e8f3
commit 291a22a4a4
14 changed files with 1605 additions and 31 deletions
@@ -363,6 +363,37 @@ void main() {
expect(calls, isEmpty);
});
test('each shelf drain pass publishes online server sources before shelf items', () async {
final events = <String>[];
final sourceClients = <List<MediaServerClient>>[];
final scoped = DiscoverProvider(
multiServer,
hiddenLibraries,
libraries,
profileId: 'profile-a',
isProfileBinding: () => isBinding,
syncSystemShelf: (owner, items) async => events.add('sync:$owner'),
syncServerSources: (owner, clients) async {
events.add('sources:$owner');
sourceClients.add(clients);
},
);
addTearDown(scoped.dispose);
aggregation.onDeckResult = () => [_item('a')];
aggregation.hubsResult = () => [_hub('hub-1')];
await scoped.load();
await pumpEventQueue();
expect(events, isNotEmpty);
expect(events.length.isEven, isTrue);
for (var i = 0; i < events.length; i += 2) {
expect(events[i], 'sources:profile-a');
expect(events[i + 1], 'sync:profile-a');
}
expect(sourceClients.first.single, same(client));
});
test('sub-threshold progress patches the row without refetching', () async {
final playing = _item('ep-1').copyWith(durationMs: 100000, viewOffsetMs: 10000, viewCount: 0);
aggregation.onDeckResult = () => [playing, for (var i = 2; i <= 21; i++) _item('ep-$i')];
+196 -4
View File
@@ -1,19 +1,27 @@
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');
@@ -30,9 +38,8 @@ class _ShelfClient implements MediaServerClient {
@override
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) {
if (throwOnThumbnail) throw StateError('conversion failed');
expect(width, 640);
expect(height, 360);
return 'https://media.invalid/poster.jpg?token=transient';
thumbnailRequests.add((path: path, width: width, height: height));
return 'https://media.invalid$path?token=transient';
}
@override
@@ -44,6 +51,15 @@ void main() {
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);
});
@@ -150,13 +166,16 @@ void main() {
serverName: 'Server',
);
expect(await service.syncFromContinueWatching('owner-a', [item], (_) => _ShelfClient()), isTrue);
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(
@@ -179,4 +198,177 @@ void main() {
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']);
});
}