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:
@@ -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')];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user