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
@@ -24,7 +24,7 @@ class WatchNextPlugin() :
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private const val SCHEMA_VERSION = 2
internal const val SCHEMA_VERSION = 3
private var pendingDeepLink: String? = null
fun handleIntent(intent: Intent?): String? {
@@ -291,7 +291,7 @@ class WatchNextProviderTest {
MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to "active-owner",
"generation" to 1L,
"items" to listOf(mapOf("contentId" to "active", "title" to "Active"))
@@ -308,7 +308,7 @@ class WatchNextProviderTest {
MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to "queued-owner",
"generation" to 2L,
"items" to listOf(mapOf("contentId" to "queued", "title" to "Queued"))
@@ -1281,7 +1281,7 @@ class WatchNextProviderTest {
private fun syncCall(ownerId: String, generation: Long) = MethodCall(
"sync",
mapOf(
"schemaVersion" to 2,
"schemaVersion" to 3,
"ownerId" to ownerId,
"generation" to generation,
"items" to emptyList<Map<String, Any?>>()
+16 -1
View File
@@ -82,10 +82,12 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
required this.isProfileBinding,
WatchStateStore? watchStateStore,
Future<void> Function(String profileId, List<MediaItem>)? syncSystemShelf,
Future<void> Function(String profileId, List<MediaServerClient> clients)? syncServerSources,
// A private field cannot be a named initializing formal callers can pass.
// ignore: prefer_initializing_formals
}) : _watchStateStore = watchStateStore,
_syncSystemShelfOverride = syncSystemShelf {
_syncSystemShelfOverride = syncSystemShelf,
_syncServerSourcesOverride = syncServerSources {
_loadCoordinator = CoalescedLoadCoordinator<String>(onFull: _loadOnce, onDelta: _loadDeltaOnce);
// Late server connects (reconnect after outage, slow wave) refresh
// discover the same way they refresh libraries. Removed in [dispose] so a
@@ -162,6 +164,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// load once binding settles).
final bool Function() isProfileBinding;
final Future<void> Function(String profileId, List<MediaItem>)? _syncSystemShelfOverride;
final Future<void> Function(String profileId, List<MediaServerClient> clients)? _syncServerSourcesOverride;
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
StreamSubscription<DeletionEvent>? _deletionSubscription;
@@ -938,7 +941,16 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (isDisposed) return;
try {
// tvOS pulls Continue Watching itself; hand the Top Shelf extension
// the current online server sources before publishing items.
final sourcesOverride = _syncServerSourcesOverride;
final syncOverride = _syncSystemShelfOverride;
if (sourcesOverride != null) {
await sourcesOverride(owner, _onlineShelfSourceClients());
} else if (syncOverride == null) {
await SystemShelfService().syncServerSources(owner, _onlineShelfSourceClients());
}
if (isDisposed) return;
if (syncOverride != null) {
await syncOverride(owner, onDeck);
continue;
@@ -972,6 +984,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
throw Exception('No owning client available for $serverId');
}
List<MediaServerClient> _onlineShelfSourceClients() =>
_multiServer.serverManager.onlineClients.values.toList(growable: false);
@override
void dispose() {
_multiServer.removeOnlineServersListener(syncToOnlineServers);
+95 -12
View File
@@ -11,13 +11,15 @@ import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import 'jellyfin_client.dart';
import 'plex_client.dart';
import 'settings_service.dart' show EpisodePosterMode;
/// Syncs Continue Watching content to platform launcher surfaces.
///
/// Android uses the Watch Next row. tvOS uses the app's Top Shelf extension.
class SystemShelfService {
static const int schemaVersion = 2;
static const int schemaVersion = 3;
static const MethodChannel _androidChannel = MethodChannel('com.plezy/watch_next');
static const MethodChannel _tvosChannel = MethodChannel('com.plezy/system_shelf');
static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
@@ -26,15 +28,19 @@ class SystemShelfService {
static SystemShelfService? _testingInstance;
factory SystemShelfService() => _testingInstance ?? _instance;
SystemShelfService._internal() : _channelOverride = null, _supportOverride = null {
SystemShelfService._internal() : _channelOverride = null, _supportOverride = null, _tvosTargetOverride = null {
_androidChannel.setMethodCallHandler(_handleMethodCall);
_tvosChannel.setMethodCallHandler(_handleMethodCall);
}
@visibleForTesting
SystemShelfService.forTesting({required MethodChannel channel, Future<bool> Function()? isSupported})
: _channelOverride = channel,
_supportOverride = isSupported;
SystemShelfService.forTesting({
required MethodChannel channel,
Future<bool> Function()? isSupported,
bool Function()? isTvosTarget,
}) : _channelOverride = channel,
_supportOverride = isSupported,
_tvosTargetOverride = isTvosTarget;
@visibleForTesting
static void debugOverrideInstance(SystemShelfService? service) {
@@ -43,6 +49,7 @@ class SystemShelfService {
final MethodChannel? _channelOverride;
final Future<bool> Function()? _supportOverride;
final bool Function()? _tvosTargetOverride;
String? _activeOwner;
int _generation = 0;
@@ -65,11 +72,20 @@ class SystemShelfService {
/// Callback for warm-start launcher surface taps.
ValueChanged<String>? onShelfItemTap;
/// Whether this process targets the tvOS Top Shelf (as opposed to the
/// Android Watch Next row). Decides artwork geometry and which native
/// surface receives server sources.
bool get _isTvosTarget {
final override = _tvosTargetOverride;
if (override != null) return override();
return Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV());
}
MethodChannel? get _channel {
final override = _channelOverride;
if (override != null) return override;
if (Platform.isAndroid) return _androidChannel;
if (Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV())) return _tvosChannel;
if (_isTvosTarget) return _tvosChannel;
return null;
}
@@ -250,6 +266,63 @@ class SystemShelfService {
return result ?? false;
}
/// Publish live server connection sources to the tvOS Top Shelf extension so
/// it can fetch Continue Watching itself. tvOS-only: Android's Watch Next
/// row is refreshed by the app process instead, so this is a no-op there.
Future<bool> syncServerSources(String profileId, List<MediaServerClient> clients) async {
if (!_isTvosTarget) return false;
final channel = _channel;
if (channel == null || _activeOwner != profileId) return false;
final generation = _generation;
final servers = clients.map(_describeServerSource).nonNulls.toList(growable: false);
final result = await _enqueueMutation<bool>(() async {
if (!_owns(profileId, generation)) return false;
return await _invokeGuarded<bool>(
channel,
'updateSources',
arguments: _envelope(profileId, generation, {'servers': servers, 'maxItems': 20}),
label: 'Failed to update system shelf sources',
severe: true,
) ??
false;
});
return result ?? false;
}
/// Backend-specific escape hatch: the Top Shelf extension talks to servers
/// directly, which needs the raw connection credentials the neutral
/// [MediaServerClient] interface deliberately hides. Unknown client types
/// and clients without a usable base URL or token are skipped.
static Map<String, dynamic>? _describeServerSource(MediaServerClient client) {
final String kind;
final String baseUrl;
final String? token;
String? userId;
if (client is PlexClient) {
kind = 'plex';
baseUrl = client.config.baseUrl;
token = client.config.token;
} else if (client is JellyfinClient) {
final connection = client.connection;
kind = connection.dialect.name;
baseUrl = connection.baseUrl;
token = connection.accessToken;
userId = connection.userId;
} else {
return null;
}
if (baseUrl.isEmpty || token == null || token.isEmpty) return null;
return {
'serverId': client.serverId,
'kind': kind,
'name': client.serverName ?? '',
'baseUrl': baseUrl,
'token': token,
'userId': ?userId,
};
}
/// Build a content ID. Format: plezy_{serverId}_{ratingKey}
static String _buildContentId(ServerId? serverId, String ratingKey) {
return 'plezy_${serverId ?? 'unknown'}_$ratingKey';
@@ -275,12 +348,22 @@ class SystemShelfService {
if (item.serverId != null) {
final client = getClientForServerId(ServerId(item.serverId!));
String? thumbPath;
if (hideSpoilers && item.shouldHideSpoiler) {
thumbPath = item.spoilerSafeArt;
}
thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 640, height: 360);
if (_isTvosTarget) {
// The Top Shelf renders 2:3 posters via the season -> series -> own
// thumb chain. Posters cannot spoil, so the spoiler-safe override
// does not apply here.
thumbPath = item.posterThumb(mode: EpisodePosterMode.seasonPoster);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 600, height: 900);
}
} else {
if (hideSpoilers && item.shouldHideSpoiler) {
thumbPath = item.spoilerSafeArt;
}
thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true);
if (thumbPath != null) {
posterSourceUri = client.thumbnailUrl(thumbPath, width: 640, height: 360);
}
}
}
} catch (_) {
@@ -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']);
});
}
+29 -4
View File
@@ -7,20 +7,27 @@
objects = {
/* Begin PBXBuildFile section */
0092CB48B2682E3D69B1A09F /* TopShelfFetchContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D44F4361594F517C002DC45 /* TopShelfFetchContractTests.swift */; };
013E913372C9904E64697D05 /* ShelfItemMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C339C20E52A6F6F6725EF31F /* ShelfItemMapper.swift */; };
055E465F9095D0B6E9B91D46 /* PathProviderPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */; };
06E9C60A796154EDE0A0D0E8 /* ShelfSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4FE4CC3847E57D0552B366 /* ShelfSources.swift */; };
1C6B234E223CDFFE5BF3FC0B /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */; };
1F6D3E8BAC792BF14F5E8B82 /* ShelfFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C792B031A97357694F027A0 /* ShelfFetcher.swift */; };
28DB4404B17342F46BC2B0A1 /* TopShelfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F829B3F190657106F66379 /* TopShelfProvider.swift */; };
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */; };
A7A0C001A7A0C001A7A0C002 /* AtmosProbeContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A0C001A7A0C001A7A0C001 /* AtmosProbeContractTests.swift */; };
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
40C477DC462980F6AF17BEA1 /* ShelfFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C792B031A97357694F027A0 /* ShelfFetcher.swift */; };
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */; };
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
63354F994A44572B97894B14 /* ShelfItemMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C339C20E52A6F6F6725EF31F /* ShelfItemMapper.swift */; };
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; };
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
72B802AB7864268C4439E8FA /* ShelfSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4FE4CC3847E57D0552B366 /* ShelfSources.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */; };
7CE4D37BDCAD02C28DFAF38A /* ShelfItemMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C339C20E52A6F6F6725EF31F /* ShelfItemMapper.swift */; };
81325C1CD13794375A81AC02 /* messages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CD411CCD84E381C4BF4C1B /* messages.g.swift */; };
81F08E404EBEB19B00FF148D /* ConnectivityPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */; };
89CF031971F407719E4B5DD8 /* TvosEventDeliveryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */; };
@@ -28,8 +35,11 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
9C477B37BFAA8E9B574919C8 /* ShelfFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C792B031A97357694F027A0 /* ShelfFetcher.swift */; };
A2D30F4B4B0761675401921F /* ShelfSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4FE4CC3847E57D0552B366 /* ShelfSources.swift */; };
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */; };
A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */; };
A7A0C001A7A0C001A7A0C002 /* AtmosProbeContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A0C001A7A0C001A7A0C001 /* AtmosProbeContractTests.swift */; };
AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; };
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F00110000000016 /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */; };
@@ -93,12 +103,14 @@
1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1CC83B6BDD3EF9204BEF754B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = System/Library/Frameworks/TVServices.framework; sourceTree = SDKROOT; };
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MpvPlayerContractTests.swift; sourceTree = "<group>"; };
34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterNativeTextInputTests.mm; sourceTree = "<group>"; };
5D44F4361594F517C002DC45 /* TopShelfFetchContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfFetchContractTests.swift; sourceTree = "<group>"; };
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = "<group>"; };
@@ -119,19 +131,21 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9C792B031A97357694F027A0 /* ShelfFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShelfFetcher.swift; sourceTree = "<group>"; };
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; };
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; };
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MpvPlayerContractTests.swift; sourceTree = "<group>"; };
A7A0C001A7A0C001A7A0C001 /* AtmosProbeContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtmosProbeContractTests.swift; sourceTree = "<group>"; };
A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; };
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPluginTests.swift; sourceTree = "<group>"; };
A7A0C001A7A0C001A7A0C001 /* AtmosProbeContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtmosProbeContractTests.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; };
C339C20E52A6F6F6725EF31F /* ShelfItemMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShelfItemMapper.swift; sourceTree = "<group>"; };
D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; };
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; };
DC4FE4CC3847E57D0552B366 /* ShelfSources.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShelfSources.swift; sourceTree = "<group>"; };
EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
@@ -203,6 +217,9 @@
F2F829B3F190657106F66379 /* TopShelfProvider.swift */,
BBCB49C8AE9E90DEF97A87CA /* Info.plist */,
9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */,
9C792B031A97357694F027A0 /* ShelfFetcher.swift */,
C339C20E52A6F6F6725EF31F /* ShelfItemMapper.swift */,
DC4FE4CC3847E57D0552B366 /* ShelfSources.swift */,
);
name = TopShelfExtension;
path = TopShelfExtension;
@@ -347,6 +364,7 @@
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */,
5D44F4361594F517C002DC45 /* TopShelfFetchContractTests.swift */,
);
name = RunnerTests;
path = RunnerTests;
@@ -602,6 +620,7 @@
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */,
0092CB48B2682E3D69B1A09F /* TopShelfFetchContractTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -628,6 +647,9 @@
CD1C0534948840272E58248E /* PathMonitorConnectivityProvider.swift in Sources */,
D2A548D9DE1A0F319B30B74C /* SystemShelfPlugin.swift in Sources */,
89CF031971F407719E4B5DD8 /* TvosEventDeliveryCoordinator.swift in Sources */,
40C477DC462980F6AF17BEA1 /* ShelfFetcher.swift in Sources */,
7CE4D37BDCAD02C28DFAF38A /* ShelfItemMapper.swift in Sources */,
06E9C60A796154EDE0A0D0E8 /* ShelfSources.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -636,6 +658,9 @@
buildActionMask = 2147483647;
files = (
28DB4404B17342F46BC2B0A1 /* TopShelfProvider.swift in Sources */,
1F6D3E8BAC792BF14F5E8B82 /* ShelfFetcher.swift in Sources */,
63354F994A44572B97894B14 /* ShelfItemMapper.swift in Sources */,
72B802AB7864268C4439E8FA /* ShelfSources.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+147 -1
View File
@@ -1,6 +1,7 @@
import CryptoKit
import Foundation
import ImageIO
import Security
import TVServices
#if os(tvOS)
@@ -264,9 +265,11 @@ import TVServices
}
final class SystemShelfPlugin: NSObject, FlutterPlugin {
static let schemaVersion = 2
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let cacheDataKey = "PlezySystemShelfCacheData"
static let sourcesKey = "PlezySystemShelfSources"
static let tokenKeychainService = "com.edde746.plezy.systemshelf.tokens"
static let artworkDirectoryName = "SystemShelfArtwork"
private static let maxItems = 20
private static let maxImageBytes = 2 * 1024 * 1024
@@ -327,6 +330,18 @@ import TVServices
return
}
Self.perform(result) { Self.sync(envelope: envelope, rawItems: items) }
case "updateSources":
guard let envelope = Self.envelope(call.arguments, engineEpoch: engineEpoch),
let raw = call.arguments as? [String: Any],
let servers = raw["servers"] as? [[String: Any]]
else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid shelf envelope", details: nil))
return
}
let maxItems = (raw["maxItems"] as? NSNumber)?.intValue
Self.perform(result) {
Self.updateSources(envelope: envelope, rawServers: servers, maxItems: maxItems)
}
case "clear":
guard let envelope = Self.envelope(call.arguments, engineEpoch: engineEpoch) else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid shelf envelope", details: nil))
@@ -542,6 +557,88 @@ import TVServices
return true
}
private static func updateSources(
envelope: SystemShelfMutationEnvelope,
rawServers: [[String: Any]],
maxItems: Int?
) -> Bool {
guard let defaults = sharedDefaults else { return false }
return updateSources(
envelope: envelope,
rawServers: rawServers,
maxItems: maxItems,
state: &mutationState,
defaults: defaults,
storeTokens: storeSourceTokens,
notifyChange: {
TVTopShelfContentProvider.topShelfContentDidChange()
}
)
}
/// Persists token-free source descriptors to app-group defaults and the
/// serverId-to-token map to the keychain so the Top Shelf extension can
/// fetch Continue Watching live. Tokens never touch defaults or logs.
static func updateSources(
envelope: SystemShelfMutationEnvelope,
rawServers: [[String: Any]],
maxItems: Int?,
state: inout SystemShelfMutationState,
defaults: UserDefaults,
storeTokens: (String, [String: String]) -> Bool,
notifyChange: () -> Void
) -> Bool {
guard state.accepts(envelope) else { return false }
var descriptors: [[String: Any]] = []
var tokens: [String: String] = [:]
for raw in rawServers {
guard let serverId = raw["serverId"] as? String, !serverId.isEmpty,
tokens[serverId] == nil,
let kind = raw["kind"] as? String, ["plex", "jellyfin", "emby"].contains(kind),
let name = raw["name"] as? String,
let baseUrl = raw["baseUrl"] as? String, isHttpUrl(baseUrl),
let token = raw["token"] as? String, !token.isEmpty
else { continue }
var descriptor: [String: Any] = [
"serverId": serverId, "kind": kind, "name": name, "baseUrl": baseUrl,
]
if let userId = raw["userId"] as? String, !userId.isEmpty {
descriptor["userId"] = userId
}
descriptors.append(descriptor)
tokens[serverId] = token
}
let payload: [String: Any] = [
"schemaVersion": schemaVersion,
"ownerId": envelope.ownerId,
"updatedAt": Date().timeIntervalSince1970,
"maxItems": min(max(maxItems ?? Self.maxItems, 1), Self.maxItems),
"servers": descriptors,
]
guard
state.commit(
envelope,
operation: {
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload),
storeTokens(envelope.ownerId, tokens)
else { return false }
defaults.set(data, forKey: sourcesKey)
defaults.synchronize()
return true
}
)
else { return false }
notifyChange()
return true
}
private static func isHttpUrl(_ value: String) -> Bool {
guard let url = URL(string: value) else { return false }
return ["http", "https"].contains(url.scheme?.lowercased() ?? "")
&& url.host?.isEmpty == false
}
private static func materialize(
source: String,
directory: URL,
@@ -698,6 +795,9 @@ import TVServices
state: &mutationState,
defaults: defaults,
artworkRoot: artworkRoot,
clearSourceTokens: {
deleteAllSourceTokens()
},
notifyChange: {
TVTopShelfContentProvider.topShelfContentDidChange()
}
@@ -709,6 +809,7 @@ import TVServices
state: inout SystemShelfMutationState,
defaults: UserDefaults,
artworkRoot: URL?,
clearSourceTokens: () -> Void = {},
notifyChange: () -> Void
) -> Bool {
guard
@@ -717,12 +818,14 @@ import TVServices
clearing: true,
operation: {
defaults.removeObject(forKey: cacheDataKey)
defaults.removeObject(forKey: sourcesKey)
defaults.synchronize()
return true
}
)
else { return false }
state.cancelAllPruning()
clearSourceTokens()
if let artworkRoot {
try? FileManager.default.removeItem(at: artworkRoot)
}
@@ -797,6 +900,7 @@ import TVServices
private static func scrubLegacyPayload() {
guard let defaults = sharedDefaults else { return }
scrubLegacySources(defaults: defaults)
var validOwner: String?
if let data = defaults.data(forKey: cacheDataKey),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -840,6 +944,48 @@ import TVServices
SHA256.hash(data: Data(owner.utf8)).map { String(format: "%02x", $0) }.joined()
}
/// Removes stale live-fetch sources (and their keychain tokens) whose
/// persisted payload no longer matches the current schema or lost its
/// owner; valid sources are left alone even when the item cache is empty.
private static func scrubLegacySources(defaults: UserDefaults) {
guard let data = defaults.data(forKey: sourcesKey) else { return }
if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(payload["schemaVersion"] as? NSNumber)?.intValue == schemaVersion,
let owner = payload["ownerId"] as? String,
!owner.isEmpty
{
return
}
defaults.removeObject(forKey: sourcesKey)
defaults.synchronize()
deleteAllSourceTokens()
}
private static func storeSourceTokens(ownerId: String, tokens: [String: String]) -> Bool {
guard JSONSerialization.isValidJSONObject(tokens),
let data = try? JSONSerialization.data(withJSONObject: tokens)
else { return false }
var attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenKeychainService,
kSecAttrAccessGroup as String: appGroupIdentifier,
kSecAttrAccount as String: ownerHash(ownerId),
]
SecItemDelete(attributes as CFDictionary)
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
private static func deleteAllSourceTokens() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenKeychainService,
kSecAttrAccessGroup as String: appGroupIdentifier,
]
SecItemDelete(query as CFDictionary)
}
static func pruneRejectedSync(ownerDirectory: URL, removing: Set<String>) {
for key in removing {
let file = ownerDirectory.appendingPathComponent(key, isDirectory: false)
@@ -0,0 +1,409 @@
import Foundation
import XCTest
@testable import Runner
// Contract tests for the Top Shelf live-fetch pipeline. The extension
// sources under test (ShelfSources/ShelfItemMapper/ShelfFetcher) also compile
// into the Runner app target, which this hosted bundle imports testably.
// No network is touched.
final class TopShelfFetchContractTests: XCTestCase {
private let token = "secret-token-123"
private let baseUrl = "https://media.example.com:32400"
private let serverId = "srv-1"
// MARK: - Plex mapping
private static let plexContinueWatchingFixture = """
{
"MediaContainer": {
"Metadata": [
{
"ratingKey": "101",
"type": "episode",
"title": "The Beach",
"grandparentTitle": "Lost",
"parentIndex": 2,
"index": 5,
"duration": 3600000,
"viewOffset": 900000,
"lastViewedAt": 1700000100,
"parentThumb": "/library/metadata/55/thumb/111",
"grandparentThumb": "/library/metadata/50/thumb/99",
"thumb": "/library/metadata/101/thumb/33"
},
{
"ratingKey": "202",
"type": "movie",
"title": "Heat",
"duration": 10200000,
"viewOffset": 5100000,
"lastViewedAt": 1700000200,
"thumb": "/library/metadata/202/thumb/77"
}
]
}
}
"""
func testPlexEpisodeMappingUsesSeriesTitleAndSeasonPoster() throws {
let items = ShelfItemMapper.plexItems(
fromResponse: try fixture(Self.plexContinueWatchingFixture),
serverId: serverId,
baseUrl: baseUrl,
token: token
)
XCTAssertEqual(items.count, 2)
let episode = items[0]
XCTAssertEqual(episode.contentId, "plezy_srv-1_101")
XCTAssertEqual(episode.title, "Lost")
XCTAssertEqual(episode.episodeTitle, "The Beach")
XCTAssertEqual(episode.seasonNumber, 2)
XCTAssertEqual(episode.episodeNumber, 5)
XCTAssertEqual(episode.durationMilliseconds, 3_600_000)
XCTAssertEqual(episode.lastPlaybackPositionMilliseconds, 900_000)
XCTAssertEqual(try XCTUnwrap(episode.playbackProgress), 0.25, accuracy: 0.0001)
XCTAssertEqual(episode.recency, 1_700_000_100)
// Season poster (parentThumb) wins the chain.
XCTAssertEqual(
episode.posterUrl,
"\(baseUrl)/photo/:/transcode?width=600&height=900&minSize=1&upscale=1"
+ "&url=%2Flibrary%2Fmetadata%2F55%2Fthumb%2F111&X-Plex-Token=\(token)"
)
let movie = items[1]
XCTAssertEqual(movie.contentId, "plezy_srv-1_202")
XCTAssertEqual(movie.title, "Heat")
XCTAssertNil(movie.episodeTitle)
XCTAssertNil(movie.seasonNumber)
XCTAssertNil(movie.episodeNumber)
XCTAssertEqual(try XCTUnwrap(movie.playbackProgress), 0.5, accuracy: 0.0001)
XCTAssertEqual(
movie.posterUrl,
"\(baseUrl)/photo/:/transcode?width=600&height=900&minSize=1&upscale=1"
+ "&url=%2Flibrary%2Fmetadata%2F202%2Fthumb%2F77&X-Plex-Token=\(token)"
)
}
func testPlexPosterChainFallsBackThroughSeriesThenOwnThumb() {
let base: [String: Any] = ["ratingKey": "1", "type": "episode", "title": "Ep", "grandparentTitle": "Show"]
var metadata = base
metadata["grandparentThumb"] = "/library/metadata/50/thumb/99"
metadata["thumb"] = "/library/metadata/1/thumb/1"
let seriesPoster = ShelfItemMapper.plexItem(metadata, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(
seriesPoster?.posterUrl?.contains("url=%2Flibrary%2Fmetadata%2F50%2Fthumb%2F99"),
true
)
metadata = base
metadata["thumb"] = "/library/metadata/1/thumb/1"
let ownThumb = ShelfItemMapper.plexItem(metadata, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(ownThumb?.posterUrl?.contains("url=%2Flibrary%2Fmetadata%2F1%2Fthumb%2F1"), true)
let bare = ShelfItemMapper.plexItem(base, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertNotNil(bare)
XCTAssertNil(bare?.posterUrl)
}
func testPlexHubWrappedResponseIsTolerated() throws {
let wrapped = """
{
"MediaContainer": {
"Hub": [
{"Metadata": [{"ratingKey": "7", "type": "movie", "title": "Alien", "thumb": "/library/metadata/7/thumb/1"}]},
{"Metadata": [{"ratingKey": "8", "type": "movie", "title": "Aliens"}]}
]
}
}
"""
let items = ShelfItemMapper.plexItems(
fromResponse: try fixture(wrapped),
serverId: serverId,
baseUrl: baseUrl,
token: token
)
XCTAssertEqual(items.map(\.contentId), ["plezy_srv-1_7", "plezy_srv-1_8"])
}
// MARK: - MediaBrowser mapping
private static let mediaBrowserFixture = """
{
"Items": [
{
"Id": "ep1",
"Type": "Episode",
"Name": "Winter Is Coming",
"SeriesName": "Game of Thrones",
"ParentIndexNumber": 1,
"IndexNumber": 3,
"RunTimeTicks": 36000000000,
"SeasonId": "season-9",
"SeriesId": "series-4",
"UserData": {
"PlaybackPositionTicks": 9000000000,
"LastPlayedDate": "2023-11-14T22:13:20.0000000Z"
}
}
]
}
"""
func testMediaBrowserEpisodeMappingConvertsTicksAndPrefersSeasonPoster() throws {
let items = ShelfItemMapper.mediaBrowserItems(
fromResponse: try fixture(Self.mediaBrowserFixture),
serverId: serverId,
baseUrl: baseUrl,
token: token
)
let episode = try XCTUnwrap(items.first)
XCTAssertEqual(episode.contentId, "plezy_srv-1_ep1")
XCTAssertEqual(episode.title, "Game of Thrones")
XCTAssertEqual(episode.episodeTitle, "Winter Is Coming")
XCTAssertEqual(episode.seasonNumber, 1)
XCTAssertEqual(episode.episodeNumber, 3)
// RunTimeTicks / PlaybackPositionTicks are 100 ns ticks; divide by 10_000
// for milliseconds.
XCTAssertEqual(episode.durationMilliseconds, 3_600_000)
XCTAssertEqual(episode.lastPlaybackPositionMilliseconds, 900_000)
XCTAssertEqual(try XCTUnwrap(episode.playbackProgress), 0.25, accuracy: 0.0001)
XCTAssertEqual(episode.recency, 1_700_000_000)
XCTAssertEqual(
episode.posterUrl,
"\(baseUrl)/Items/season-9/Images/Primary?maxWidth=600&maxHeight=900&api_key=\(token)"
)
}
func testMediaBrowserPosterFallsBackToSeriesThenOwnImage() {
let base: [String: Any] = ["Id": "ep1", "Type": "Episode", "Name": "Ep", "SeriesName": "Show"]
var item = base
item["SeriesId"] = "series-4"
let seriesPoster = ShelfItemMapper.mediaBrowserItem(item, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(seriesPoster?.posterUrl?.contains("/Items/series-4/Images/Primary"), true)
let ownPoster = ShelfItemMapper.mediaBrowserItem(base, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(ownPoster?.posterUrl?.contains("/Items/ep1/Images/Primary"), true)
}
func testMediaBrowserDateParsingToleratesSevenDigitFractions() {
XCTAssertEqual(ShelfItemMapper.parseMediaBrowserDate("2023-11-14T22:13:20.0000000Z"), 1_700_000_000)
XCTAssertEqual(ShelfItemMapper.parseMediaBrowserDate("2023-11-14T22:13:20Z"), 1_700_000_000)
XCTAssertNil(ShelfItemMapper.parseMediaBrowserDate("not a date"))
}
// MARK: - Source descriptors
private static let sourcesFixture = """
{
"schemaVersion": 3,
"ownerId": "profile-a",
"maxItems": 20,
"servers": [
{"serverId": "srv-plex", "kind": "plex", "name": "Den", "baseUrl": "https://plex.example.com"},
{"serverId": "srv-jelly", "kind": "jellyfin", "name": "Attic", "baseUrl": "https://jf.example.com", "userId": "user-1"}
]
}
"""
func testSourcesResolveJoinsKeychainTokens() throws {
let sources = try XCTUnwrap(
ShelfSourceStore.resolve(
payloadData: Data(Self.sourcesFixture.utf8),
tokensByServerId: ["srv-plex": "tok-a", "srv-jelly": "tok-b"]
)
)
XCTAssertEqual(sources.ownerId, "profile-a")
XCTAssertEqual(sources.maxItems, 20)
XCTAssertEqual(sources.servers.count, 2)
XCTAssertEqual(sources.servers[0].descriptor.kind, .plex)
XCTAssertEqual(sources.servers[0].token, "tok-a")
XCTAssertEqual(sources.servers[1].descriptor.userId, "user-1")
}
func testSourcesResolveRejectsWrongSchemaAndEmptyOwner() {
let wrongSchema = Self.sourcesFixture.replacingOccurrences(
of: "\"schemaVersion\": 3",
with: "\"schemaVersion\": 2"
)
XCTAssertNil(
ShelfSourceStore.resolve(
payloadData: Data(wrongSchema.utf8),
tokensByServerId: ["srv-plex": "tok-a"]
)
)
let emptyOwner = Self.sourcesFixture.replacingOccurrences(
of: "\"ownerId\": \"profile-a\"",
with: "\"ownerId\": \"\""
)
XCTAssertNil(
ShelfSourceStore.resolve(
payloadData: Data(emptyOwner.utf8),
tokensByServerId: ["srv-plex": "tok-a"]
)
)
}
func testSourcesResolveSkipsServersWithoutTokensAndMalformedSiblings() throws {
// A server with no keychain token is dropped; all dropped means nil.
let partial = try XCTUnwrap(
ShelfSourceStore.resolve(
payloadData: Data(Self.sourcesFixture.utf8),
tokensByServerId: ["srv-jelly": "tok-b"]
)
)
XCTAssertEqual(partial.servers.map(\.descriptor.serverId), ["srv-jelly"])
XCTAssertNil(
ShelfSourceStore.resolve(payloadData: Data(Self.sourcesFixture.utf8), tokensByServerId: [:])
)
// An unknown kind only sinks its own entry, not the whole payload.
let malformedSibling = Self.sourcesFixture.replacingOccurrences(
of: "\"kind\": \"plex\"",
with: "\"kind\": \"unknown\""
)
let survivors = try XCTUnwrap(
ShelfSourceStore.resolve(
payloadData: Data(malformedSibling.utf8),
tokensByServerId: ["srv-plex": "tok-a", "srv-jelly": "tok-b"]
)
)
XCTAssertEqual(survivors.servers.map(\.descriptor.serverId), ["srv-jelly"])
}
// MARK: - Merge
func testMergeOrdersByRecencyDescendingDeduplicatesAndCaps() {
let merged = ShelfItemMapper.merge(
[
[makeItem("a", recency: 10), makeItem("b", recency: 5)],
[makeItem("c", recency: 20), makeItem("a", recency: 99)],
],
maxItems: 2
)
// "a" keeps its first (resume-ordered) occurrence, then recency sorts.
XCTAssertEqual(merged.map(\.contentId), ["c", "a"])
XCTAssertEqual(merged[1].recency, 10)
let uncapped = ShelfItemMapper.merge([[makeItem("b", recency: 0), makeItem("a", recency: 0)]], maxItems: 20)
// Equal recency keeps input order.
XCTAssertEqual(uncapped.map(\.contentId), ["b", "a"])
}
// MARK: - Request construction
func testPlexContinueWatchingRequestCarriesTokenOnlyAsHeader() throws {
let request = try XCTUnwrap(
ShelfFetcher.plexContinueWatchingRequest(baseUrl: baseUrl + "/", token: token, maxItems: 20)
)
let url = try XCTUnwrap(request.url?.absoluteString)
XCTAssertEqual(url, "\(baseUrl)/hubs/continueWatching?count=20&includeGuids=1")
XCTAssertFalse(url.contains(token))
let headers = request.allHTTPHeaderFields ?? [:]
XCTAssertEqual(headers["X-Plex-Token"], token)
XCTAssertEqual(headers["Accept"], "application/json")
XCTAssertEqual(headers.values.filter { $0.contains(token) }.count, 1)
}
func testMediaBrowserRequestsCarryTokenOnlyAsHeader() throws {
let resume = try XCTUnwrap(
ShelfFetcher.mediaBrowserResumeRequest(baseUrl: baseUrl, token: token, userId: "user-1", maxItems: 20)
)
XCTAssertEqual(
resume.url?.absoluteString,
"\(baseUrl)/UserItems/Resume?userId=user-1&Limit=20&MediaTypes=Video"
+ "&Recursive=true&EnableTotalRecordCount=false"
)
let nextUp = try XCTUnwrap(
ShelfFetcher.mediaBrowserNextUpRequest(baseUrl: baseUrl, token: token, userId: "user-1", maxItems: 20)
)
XCTAssertEqual(
nextUp.url?.absoluteString,
"\(baseUrl)/Shows/NextUp?userId=user-1&Limit=20"
+ "&EnableResumable=false&EnableTotalRecordCount=false"
)
for request in [resume, nextUp] {
let url = try XCTUnwrap(request.url?.absoluteString)
XCTAssertFalse(url.contains(token))
let headers = request.allHTTPHeaderFields ?? [:]
XCTAssertEqual(headers["X-Emby-Token"], token)
XCTAssertEqual(headers.values.filter { $0.contains(token) }.count, 1)
}
}
func testPosterUrlsCarryTokenAsQueryParameterExactlyOnce() throws {
let plex = try XCTUnwrap(
ShelfItemMapper.plexPosterUrl(path: "/library/metadata/55/thumb/1", baseUrl: baseUrl, token: token)
)
XCTAssertEqual(occurrences(of: token, in: plex), 1)
XCTAssertEqual(occurrences(of: "X-Plex-Token=", in: plex), 1)
let mediaBrowser = try XCTUnwrap(
ShelfItemMapper.mediaBrowserPosterUrl(itemId: "season-9", baseUrl: baseUrl, token: token)
)
XCTAssertEqual(occurrences(of: token, in: mediaBrowser), 1)
XCTAssertEqual(occurrences(of: "api_key=", in: mediaBrowser), 1)
}
// MARK: - Token redaction
func testTokenNeverAppearsInDescriptionOrDebugStrings() throws {
let source = ShelfServerSource(
descriptor: ShelfServerDescriptor(
serverId: "srv-plex",
kind: .plex,
name: "Den",
baseUrl: baseUrl,
userId: nil
),
token: token
)
XCTAssertFalse(String(describing: source).contains(token))
XCTAssertFalse(String(reflecting: source).contains(token))
XCTAssertFalse(source.description.contains(token))
XCTAssertFalse(source.debugDescription.contains(token))
let item = try XCTUnwrap(
ShelfItemMapper.plexItem(
["ratingKey": "1", "title": "Heat", "thumb": "/library/metadata/1/thumb/1"],
serverId: serverId,
baseUrl: baseUrl,
token: token
)
)
XCTAssertEqual(item.posterUrl?.contains(token), true)
XCTAssertFalse(String(describing: item).contains(token))
XCTAssertFalse(String(reflecting: item).contains(token))
}
// MARK: - Helpers
private func fixture(_ json: String) throws -> Any {
try JSONSerialization.jsonObject(with: Data(json.utf8))
}
private func makeItem(_ contentId: String, recency: Double) -> ShelfFetchedItem {
ShelfFetchedItem(
contentId: contentId,
title: contentId,
episodeTitle: nil,
type: "movie",
seasonNumber: nil,
episodeNumber: nil,
durationMilliseconds: nil,
lastPlaybackPositionMilliseconds: nil,
posterUrl: nil,
recency: recency
)
}
private func occurrences(of needle: String, in haystack: String) -> Int {
haystack.components(separatedBy: needle).count - 1
}
}
+192
View File
@@ -0,0 +1,192 @@
import Foundation
// Live Continue Watching fetch for the Top Shelf extension. No Flutter
// import: compiles into both TopShelfExtension and RunnerTests.
enum ShelfFetcher {
/// Per-request budget; servers are queried in parallel, so the overall
/// fetch stays inside the ~4 s extension budget.
static let perRequestTimeout: TimeInterval = 3.5
static let maxResponseBytes = 4 * 1024 * 1024
/// Fetches Continue Watching from every source in parallel. Per-server
/// failures are dropped silently; returns nil unless at least one server
/// responded, so callers can fall back to the cached payload.
static func fetchContinueWatching(sources: ShelfSourceStore.Sources) async -> [ShelfFetchedItem]? {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = perRequestTimeout
configuration.timeoutIntervalForResource = perRequestTimeout
configuration.waitsForConnectivity = false
let session = URLSession(configuration: configuration)
defer { session.finishTasksAndInvalidate() }
var groups = [[ShelfFetchedItem]?](repeating: nil, count: sources.servers.count)
await withTaskGroup(of: (Int, [ShelfFetchedItem]?).self) { group in
for (index, server) in sources.servers.enumerated() {
group.addTask {
let items = await fetchServer(server, maxItems: sources.maxItems, session: session)
return (index, items)
}
}
for await (index, items) in group {
groups[index] = items
}
}
let succeeded = groups.compactMap { $0 }
guard !succeeded.isEmpty else { return nil }
return ShelfItemMapper.merge(succeeded, maxItems: sources.maxItems)
}
// MARK: - Request construction (pure; exercised by RunnerTests)
static func plexContinueWatchingRequest(
baseUrl: String,
token: String,
maxItems: Int
) -> URLRequest? {
guard !token.isEmpty,
let url = serverURL(
baseUrl: baseUrl,
path: "/hubs/continueWatching",
query: "count=\(maxItems)&includeGuids=1"
)
else { return nil }
var request = URLRequest(url: url)
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
static func mediaBrowserResumeRequest(
baseUrl: String,
token: String,
userId: String,
maxItems: Int
) -> URLRequest? {
mediaBrowserRequest(
baseUrl: baseUrl,
token: token,
userId: userId,
path: "/UserItems/Resume",
trailingQuery: "MediaTypes=Video&Recursive=true&EnableTotalRecordCount=false",
maxItems: maxItems
)
}
static func mediaBrowserNextUpRequest(
baseUrl: String,
token: String,
userId: String,
maxItems: Int
) -> URLRequest? {
mediaBrowserRequest(
baseUrl: baseUrl,
token: token,
userId: userId,
path: "/Shows/NextUp",
trailingQuery: "EnableResumable=false&EnableTotalRecordCount=false",
maxItems: maxItems
)
}
private static func mediaBrowserRequest(
baseUrl: String,
token: String,
userId: String,
path: String,
trailingQuery: String,
maxItems: Int
) -> URLRequest? {
guard !token.isEmpty, !userId.isEmpty,
let encodedUserId = ShelfItemMapper.encodeQueryComponent(userId),
let url = serverURL(
baseUrl: baseUrl,
path: path,
query: "userId=\(encodedUserId)&Limit=\(maxItems)&\(trailingQuery)"
)
else { return nil }
var request = URLRequest(url: url)
request.setValue(token, forHTTPHeaderField: "X-Emby-Token")
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
private static func serverURL(baseUrl: String, path: String, query: String) -> URL? {
guard let base = ShelfItemMapper.normalizedBaseUrl(baseUrl) else { return nil }
return URL(string: "\(base)\(path)?\(query)")
}
// MARK: - Transport
private static func fetchServer(
_ server: ShelfServerSource,
maxItems: Int,
session: URLSession
) async -> [ShelfFetchedItem]? {
let descriptor = server.descriptor
switch descriptor.kind {
case .plex:
guard
let request = plexContinueWatchingRequest(
baseUrl: descriptor.baseUrl,
token: server.token,
maxItems: maxItems
),
let object = await fetchJSON(request, session: session)
else { return nil }
return ShelfItemMapper.plexItems(
fromResponse: object,
serverId: descriptor.serverId,
baseUrl: descriptor.baseUrl,
token: server.token
)
case .jellyfin, .emby:
guard let userId = descriptor.userId, !userId.isEmpty,
let resumeRequest = mediaBrowserResumeRequest(
baseUrl: descriptor.baseUrl,
token: server.token,
userId: userId,
maxItems: maxItems
),
let nextUpRequest = mediaBrowserNextUpRequest(
baseUrl: descriptor.baseUrl,
token: server.token,
userId: userId,
maxItems: maxItems
)
else { return nil }
async let resumeObject = fetchJSON(resumeRequest, session: session)
async let nextUpObject = fetchJSON(nextUpRequest, session: session)
// Resume first so in-progress items win contentId deduplication.
let responses = [await resumeObject, await nextUpObject].compactMap { $0 }
guard !responses.isEmpty else { return nil }
return responses.flatMap {
ShelfItemMapper.mediaBrowserItems(
fromResponse: $0,
serverId: descriptor.serverId,
baseUrl: descriptor.baseUrl,
token: server.token
)
}
}
}
private static func fetchJSON(_ request: URLRequest, session: URLSession) async -> Any? {
await withCheckedContinuation { continuation in
let task = session.dataTask(with: request) { data, response, error in
guard error == nil,
let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode),
let data,
data.count <= maxResponseBytes,
let object = try? JSONSerialization.jsonObject(with: data)
else {
continuation.resume(returning: nil)
return
}
continuation.resume(returning: object)
}
task.resume()
}
}
}
@@ -0,0 +1,243 @@
import Foundation
// Pure response-to-item mapping for the live Top Shelf fetch. No Flutter
// import: compiles into both TopShelfExtension and RunnerTests.
/// One Continue Watching entry mapped from a media-server response, in the
/// cached-payload item shape (durations/positions in milliseconds).
struct ShelfFetchedItem {
let contentId: String
let title: String
let episodeTitle: String?
let type: String?
let seasonNumber: Int?
let episodeNumber: Int?
let durationMilliseconds: Double?
let lastPlaybackPositionMilliseconds: Double?
let posterUrl: String?
/// Epoch seconds of the last engagement; 0 when the server did not say.
let recency: Double
var playbackProgress: Double? {
guard let duration = durationMilliseconds, duration > 0,
let position = lastPlaybackPositionMilliseconds, position > 0
else { return nil }
return min(max(position / duration, 0), 1)
}
}
extension ShelfFetchedItem: CustomStringConvertible, CustomDebugStringConvertible {
// posterUrl embeds the server token; keep it out of any diagnostic string.
var description: String { "ShelfFetchedItem(contentId: \(contentId))" }
var debugDescription: String { description }
}
enum ShelfItemMapper {
static let posterWidth = 600
static let posterHeight = 900
// MARK: - Plex
/// Parses `MediaContainer.Metadata`, tolerating the hub-wrapped
/// `MediaContainer.Hub[].Metadata` shape `/hubs` responses use.
static func plexItems(
fromResponse object: Any,
serverId: String,
baseUrl: String,
token: String
) -> [ShelfFetchedItem] {
guard let root = object as? [String: Any],
let container = root["MediaContainer"] as? [String: Any]
else { return [] }
var metadata = container["Metadata"] as? [[String: Any]] ?? []
if metadata.isEmpty, let hubs = container["Hub"] as? [[String: Any]] {
metadata = hubs.flatMap { $0["Metadata"] as? [[String: Any]] ?? [] }
}
return metadata.compactMap {
plexItem($0, serverId: serverId, baseUrl: baseUrl, token: token)
}
}
static func plexItem(
_ metadata: [String: Any],
serverId: String,
baseUrl: String,
token: String
) -> ShelfFetchedItem? {
guard let ratingKey = flexibleString(metadata["ratingKey"]), !ratingKey.isEmpty,
let rawTitle = metadata["title"] as? String, !rawTitle.isEmpty
else { return nil }
let type = (metadata["type"] as? String)?.lowercased()
let seriesTitle = (metadata["grandparentTitle"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let isEpisode = type == "episode" && seriesTitle != nil
let posterPath = ["parentThumb", "grandparentThumb", "thumb"]
.compactMap { metadata[$0] as? String }
.first { !$0.isEmpty }
return ShelfFetchedItem(
contentId: "plezy_\(serverId)_\(ratingKey)",
title: isEpisode ? seriesTitle! : rawTitle,
episodeTitle: isEpisode ? rawTitle : nil,
type: type,
seasonNumber: isEpisode ? flexibleInt(metadata["parentIndex"]) : nil,
episodeNumber: isEpisode ? flexibleInt(metadata["index"]) : nil,
durationMilliseconds: flexibleDouble(metadata["duration"]),
lastPlaybackPositionMilliseconds: flexibleDouble(metadata["viewOffset"]),
posterUrl: posterPath.flatMap { plexPosterUrl(path: $0, baseUrl: baseUrl, token: token) },
recency: flexibleDouble(metadata["lastViewedAt"]) ?? 0
)
}
/// Mirrors PlexClient.thumbnailUrl's cover transcode (`minSize=1&upscale=1`)
/// at poster size, with the token carried exactly once as the outer
/// `X-Plex-Token` query parameter.
static func plexPosterUrl(path: String, baseUrl: String, token: String) -> String? {
guard let base = normalizedBaseUrl(baseUrl), path.hasPrefix("/"), !token.isEmpty,
let encodedPath = encodeQueryComponent(path),
let encodedToken = encodeQueryComponent(token)
else { return nil }
return "\(base)/photo/:/transcode?width=\(posterWidth)&height=\(posterHeight)"
+ "&minSize=1&upscale=1&url=\(encodedPath)&X-Plex-Token=\(encodedToken)"
}
// MARK: - Jellyfin / Emby (MediaBrowser)
static func mediaBrowserItems(
fromResponse object: Any,
serverId: String,
baseUrl: String,
token: String
) -> [ShelfFetchedItem] {
guard let root = object as? [String: Any],
let items = root["Items"] as? [[String: Any]]
else { return [] }
return items.compactMap {
mediaBrowserItem($0, serverId: serverId, baseUrl: baseUrl, token: token)
}
}
static func mediaBrowserItem(
_ item: [String: Any],
serverId: String,
baseUrl: String,
token: String
) -> ShelfFetchedItem? {
guard let id = flexibleString(item["Id"]), !id.isEmpty,
let name = item["Name"] as? String, !name.isEmpty
else { return nil }
let type = (item["Type"] as? String)?.lowercased()
let seriesName = (item["SeriesName"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let isEpisode = type == "episode" && seriesName != nil
let userData = item["UserData"] as? [String: Any] ?? [:]
// Season poster, then series poster, then the item's own primary image.
let posterItemId =
["SeasonId", "SeriesId"]
.compactMap { flexibleString(item[$0]) }
.first { !$0.isEmpty } ?? id
return ShelfFetchedItem(
contentId: "plezy_\(serverId)_\(id)",
title: isEpisode ? seriesName! : name,
episodeTitle: isEpisode ? name : nil,
type: type,
seasonNumber: isEpisode ? flexibleInt(item["ParentIndexNumber"]) : nil,
episodeNumber: isEpisode ? flexibleInt(item["IndexNumber"]) : nil,
durationMilliseconds: flexibleDouble(item["RunTimeTicks"]).map { $0 / 10_000 },
lastPlaybackPositionMilliseconds: flexibleDouble(userData["PlaybackPositionTicks"])
.map { $0 / 10_000 },
posterUrl: mediaBrowserPosterUrl(itemId: posterItemId, baseUrl: baseUrl, token: token),
recency: (userData["LastPlayedDate"] as? String).flatMap(parseMediaBrowserDate) ?? 0
)
}
/// Mirrors JellyfinClient.thumbnailUrl's `maxWidth`/`maxHeight`/`api_key`
/// parameters at poster size.
static func mediaBrowserPosterUrl(itemId: String, baseUrl: String, token: String) -> String? {
guard let base = normalizedBaseUrl(baseUrl), !itemId.isEmpty, !token.isEmpty,
let encodedId = encodeQueryComponent(itemId),
let encodedToken = encodeQueryComponent(token)
else { return nil }
return "\(base)/Items/\(encodedId)/Images/Primary"
+ "?maxWidth=\(posterWidth)&maxHeight=\(posterHeight)&api_key=\(encodedToken)"
}
/// Jellyfin emits seven fractional-second digits, which
/// ISO8601DateFormatter rejects; retry with the fraction stripped.
static func parseMediaBrowserDate(_ value: String) -> Double? {
if let date = isoFormatter.date(from: value) { return date.timeIntervalSince1970 }
guard let dotIndex = value.firstIndex(of: ".") else { return nil }
var end = value.index(after: dotIndex)
while end < value.endIndex, value[end].isNumber { end = value.index(after: end) }
let stripped = String(value[..<dotIndex]) + String(value[end...])
return isoFormatter.date(from: stripped).map(\.timeIntervalSince1970)
}
// MARK: - Merge
/// Merges per-server successes by recency (descending), deduplicates by
/// contentId (first occurrence wins), and caps at [maxItems]. Ties keep
/// their input order.
static func merge(_ groups: [[ShelfFetchedItem]], maxItems: Int) -> [ShelfFetchedItem] {
var seen = Set<String>()
var all: [ShelfFetchedItem] = []
for item in groups.joined() where seen.insert(item.contentId).inserted {
all.append(item)
}
let sorted = all.enumerated()
.sorted { lhs, rhs in
if lhs.element.recency != rhs.element.recency {
return lhs.element.recency > rhs.element.recency
}
return lhs.offset < rhs.offset
}
.map(\.element)
return Array(sorted.prefix(max(0, maxItems)))
}
// MARK: - URL building blocks
static func normalizedBaseUrl(_ baseUrl: String) -> String? {
var trimmed = baseUrl.trimmingCharacters(in: .whitespacesAndNewlines)
while trimmed.hasSuffix("/") { trimmed.removeLast() }
guard let url = URL(string: trimmed),
["http", "https"].contains(url.scheme?.lowercased() ?? ""),
url.host?.isEmpty == false
else { return nil }
return trimmed
}
/// Matches Dart's `Uri.encodeComponent`: everything but the RFC 2396
/// unreserved characters is percent-encoded.
static func encodeQueryComponent(_ value: String) -> String? {
value.addingPercentEncoding(withAllowedCharacters: dartUnreservedCharacters)
}
private static let dartUnreservedCharacters = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"
)
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
// MARK: - Flexible scalar readers (server JSON mixes Int/Double/String)
private static func flexibleString(_ value: Any?) -> String? {
if let value = value as? String { return value }
if let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID() {
return value.stringValue
}
return nil
}
private static func flexibleDouble(_ value: Any?) -> Double? {
guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID() else {
return nil
}
return value.doubleValue.isFinite ? value.doubleValue : nil
}
private static func flexibleInt(_ value: Any?) -> Int? {
flexibleDouble(value).map { Int($0) }
}
}
+146
View File
@@ -0,0 +1,146 @@
import CryptoKit
import Foundation
import Security
// Live-fetch source descriptors persisted by SystemShelfPlugin.updateSources.
// This file must not import Flutter: it compiles into both TopShelfExtension
// and RunnerTests.
enum ShelfServerKind: String, Decodable {
case plex
case jellyfin
case emby
}
/// Token-free server descriptor as persisted in app-group defaults.
struct ShelfServerDescriptor: Decodable {
let serverId: String
let kind: ShelfServerKind
let name: String
let baseUrl: String
let userId: String?
}
/// A descriptor joined with its keychain token. Never persisted.
struct ShelfServerSource {
let descriptor: ShelfServerDescriptor
let token: String
}
extension ShelfServerSource: CustomStringConvertible, CustomDebugStringConvertible {
// The token must never leak through interpolation or reflection dumps.
var description: String {
"ShelfServerSource(serverId: \(descriptor.serverId), kind: \(descriptor.kind.rawValue))"
}
var debugDescription: String { description }
}
struct ShelfSourcesPayload: Decodable {
let schemaVersion: Int
let ownerId: String
let maxItems: Int?
let servers: [ShelfServerDescriptor]
private enum CodingKeys: String, CodingKey {
case schemaVersion
case ownerId
case maxItems
case servers
}
/// One malformed server entry (e.g. an unknown kind written by a future
/// schema) must not sink the whole shelf; siblings decode best-effort.
private struct LossyServer: Decodable {
let descriptor: ShelfServerDescriptor?
init(from decoder: Decoder) throws {
descriptor = try? ShelfServerDescriptor(from: decoder)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
ownerId = try container.decode(String.self, forKey: .ownerId)
maxItems = try container.decodeIfPresent(Int.self, forKey: .maxItems)
servers = (try container.decodeIfPresent([LossyServer].self, forKey: .servers) ?? [])
.compactMap(\.descriptor)
}
}
enum ShelfSourceStore {
/// Must stay in lockstep with TopShelfShared.schemaVersion and
/// SystemShelfPlugin.schemaVersion (duplicated here so this file compiles
/// into RunnerTests without TopShelfProvider.swift).
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let sourcesKey = "PlezySystemShelfSources"
static let tokenKeychainService = "com.edde746.plezy.systemshelf.tokens"
static let defaultMaxItems = 20
static let maxItemsLimit = 20
struct Sources {
let ownerId: String
let maxItems: Int
let servers: [ShelfServerSource]
}
static func load() -> Sources? {
guard let defaults = UserDefaults(suiteName: appGroupIdentifier),
let data = defaults.data(forKey: sourcesKey),
let payload = decodePayload(data)
else { return nil }
return join(payload: payload, tokensByServerId: loadTokens(ownerId: payload.ownerId))
}
/// Pure resolution used by tests: validates the persisted payload and
/// attaches externally supplied tokens.
static func resolve(payloadData: Data, tokensByServerId: [String: String]) -> Sources? {
guard let payload = decodePayload(payloadData) else { return nil }
return join(payload: payload, tokensByServerId: tokensByServerId)
}
static func ownerAccount(_ ownerId: String) -> String {
SHA256.hash(data: Data(ownerId.utf8)).map { String(format: "%02x", $0) }.joined()
}
private static func decodePayload(_ data: Data) -> ShelfSourcesPayload? {
guard let payload = try? JSONDecoder().decode(ShelfSourcesPayload.self, from: data),
payload.schemaVersion == schemaVersion,
!payload.ownerId.isEmpty
else { return nil }
return payload
}
private static func join(
payload: ShelfSourcesPayload,
tokensByServerId: [String: String]
) -> Sources? {
let servers = payload.servers.compactMap { descriptor -> ShelfServerSource? in
guard !descriptor.serverId.isEmpty, !descriptor.baseUrl.isEmpty,
let token = tokensByServerId[descriptor.serverId], !token.isEmpty
else { return nil }
return ShelfServerSource(descriptor: descriptor, token: token)
}
guard !servers.isEmpty else { return nil }
let maxItems = min(max(payload.maxItems ?? defaultMaxItems, 1), maxItemsLimit)
return Sources(ownerId: payload.ownerId, maxItems: maxItems, servers: servers)
}
private static func loadTokens(ownerId: String) -> [String: String] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenKeychainService,
kSecAttrAccessGroup as String: appGroupIdentifier,
kSecAttrAccount as String: ownerAccount(ownerId),
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return object.compactMapValues { $0 as? String }
}
}
+87 -5
View File
@@ -3,7 +3,7 @@ import Foundation
import TVServices
private enum TopShelfShared {
static let schemaVersion = 2
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let cacheDataKey = "PlezySystemShelfCacheData"
static let artworkDirectoryName = "SystemShelfArtwork"
@@ -28,6 +28,7 @@ private struct TopShelfCachePayload: Decodable {
let episodeTitle: String?
let description: String?
let artworkKey: String?
let posterUrl: String?
let type: String?
let duration: Double?
let lastPlaybackPosition: Double?
@@ -40,6 +41,7 @@ private struct TopShelfCachePayload: Decodable {
case episodeTitle
case description
case artworkKey
case posterUrl
case type
case duration
case lastPlaybackPosition
@@ -54,12 +56,27 @@ private struct TopShelfCachePayload: Decodable {
episodeTitle = try container.decodeIfPresent(String.self, forKey: .episodeTitle)
description = try container.decodeIfPresent(String.self, forKey: .description)
artworkKey = try container.decodeIfPresent(String.self, forKey: .artworkKey)
posterUrl = try container.decodeIfPresent(String.self, forKey: .posterUrl)
type = try container.decodeIfPresent(String.self, forKey: .type)
duration = container.decodeFlexibleDoubleIfPresent(.duration)
lastPlaybackPosition = container.decodeFlexibleDoubleIfPresent(.lastPlaybackPosition)
seasonNumber = container.decodeFlexibleIntIfPresent(.seasonNumber)
episodeNumber = container.decodeFlexibleIntIfPresent(.episodeNumber)
}
init(fetched: ShelfFetchedItem) {
contentId = fetched.contentId
title = fetched.title
episodeTitle = fetched.episodeTitle
description = nil
artworkKey = nil
posterUrl = fetched.posterUrl
type = fetched.type
duration = fetched.durationMilliseconds
lastPlaybackPosition = fetched.lastPlaybackPositionMilliseconds
seasonNumber = fetched.seasonNumber
episodeNumber = fetched.episodeNumber
}
}
let schemaVersion: Int
@@ -82,7 +99,58 @@ private extension KeyedDecodingContainer {
}
final class TopShelfProvider: TVTopShelfContentProvider {
override func loadTopShelfContent() async -> (any TVTopShelfContent)? { buildContent() }
override func loadTopShelfContent() async -> (any TVTopShelfContent)? {
// Prefer a live fetch when the app has published server sources; any
// failure falls back to replaying the last committed cache unchanged.
if let sources = ShelfSourceStore.load(),
let items = await ShelfFetcher.fetchContinueWatching(sources: sources)
{
persistLiveSnapshot(items, ownerId: sources.ownerId)
return buildLiveContent(items, ownerId: sources.ownerId)
}
return buildContent()
}
private func buildLiveContent(_ items: [ShelfFetchedItem], ownerId: String) -> TVTopShelfContent? {
let sectionItems = items.compactMap {
makeTopShelfItem(TopShelfCachePayload.Item(fetched: $0), ownerId: ownerId)
}
guard !sectionItems.isEmpty else { return nil }
let collection = TVTopShelfItemCollection(items: sectionItems)
collection.title = "Continue Watching"
return TVTopShelfSectionedContent(sections: [collection])
}
/// Rewrites the shared cache with the live result so offline replay stays
/// fresh. Token-bearing poster URLs stay inside the app group container.
private func persistLiveSnapshot(_ items: [ShelfFetchedItem], ownerId: String) {
guard let defaults = TopShelfShared.sharedDefaults else { return }
let itemDicts = items.map { item -> [String: Any] in
var dict: [String: Any] = ["contentId": item.contentId, "title": item.title]
if let episodeTitle = item.episodeTitle { dict["episodeTitle"] = episodeTitle }
if let type = item.type { dict["type"] = type }
if let duration = item.durationMilliseconds { dict["duration"] = duration }
if let position = item.lastPlaybackPositionMilliseconds {
dict["lastPlaybackPosition"] = position
}
if let seasonNumber = item.seasonNumber { dict["seasonNumber"] = seasonNumber }
if let episodeNumber = item.episodeNumber { dict["episodeNumber"] = episodeNumber }
if let posterUrl = item.posterUrl { dict["posterUrl"] = posterUrl }
if item.recency > 0 { dict["lastEngagementTime"] = item.recency }
return dict
}
let payload: [String: Any] = [
"schemaVersion": TopShelfShared.schemaVersion,
"ownerId": ownerId,
"updatedAt": Date().timeIntervalSince1970,
"sections": [["id": "continue_watching", "title": "Continue Watching", "items": itemDicts]],
]
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload)
else { return }
defaults.set(data, forKey: TopShelfShared.cacheDataKey)
defaults.synchronize()
}
private func buildContent() -> TVTopShelfContent? {
guard let defaults = TopShelfShared.sharedDefaults,
@@ -109,7 +177,7 @@ final class TopShelfProvider: TVTopShelfContentProvider {
guard !cacheItem.contentId.isEmpty else { return nil }
let item = TVTopShelfSectionedItem(identifier: cacheItem.contentId)
item.title = displayTitle(for: cacheItem)
item.imageShape = .hdtv
item.imageShape = .poster
if let duration = cacheItem.duration, duration > 0,
let position = cacheItem.lastPlaybackPosition, position > 0
@@ -121,13 +189,23 @@ final class TopShelfProvider: TVTopShelfContentProvider {
item.displayAction = action
item.playAction = action
}
if let key = cacheItem.artworkKey, let localURL = localArtworkURL(ownerId: ownerId, key: key) {
if let remoteURL = remoteArtworkURL(cacheItem.posterUrl) {
item.setImageURL(remoteURL, for: .screenScale1x)
item.setImageURL(remoteURL, for: .screenScale2x)
} else if let key = cacheItem.artworkKey, let localURL = localArtworkURL(ownerId: ownerId, key: key) {
item.setImageURL(localURL, for: .screenScale1x)
item.setImageURL(localURL, for: .screenScale2x)
}
return item
}
private func remoteArtworkURL(_ posterUrl: String?) -> URL? {
guard let posterUrl, let url = URL(string: posterUrl),
["http", "https"].contains(url.scheme?.lowercased() ?? "")
else { return nil }
return url
}
private func localArtworkURL(ownerId: String, key: String) -> URL? {
guard key.range(of: "^[a-f0-9]{32}\\.art$", options: .regularExpression) != nil,
let root = TopShelfShared.artworkRoot
@@ -157,7 +235,11 @@ final class TopShelfProvider: TVTopShelfContentProvider {
if let episodeNumber = item.episodeNumber { return "E\(episodeNumber)" }
return nil
}()
if let episodePrefix { return "\(item.title) - \(episodePrefix) - \(episodeTitle)" }
// S/E leads (official Plex app format) so it is readable immediately
// instead of after the focused-item marquee scrolls a long series name;
// the poster artwork already identifies the series. Without numbers the
// series name is the only usable context, so it stays.
if let episodePrefix { return "\(episodePrefix) - \(episodeTitle)" }
return "\(item.title) - \(episodeTitle)"
}
+10
View File
@@ -84,6 +84,11 @@ project.files.select { |file| file.display_name == 'Foundation.framework' }.each
end
RUNNER_TESTS_DIR = File.expand_path('../RunnerTests', __dir__)
COMPILED_TEST_EXTENSIONS = %w[.swift .m .mm].freeze
# Extension sources without a Flutter import; compiled into both the
# TopShelfExtension target and the Runner app so the hosted RunnerTests bundle
# reaches them via `@testable import Runner` — the test Sources phase itself
# must only contain files under RunnerTests/ (scripts/check_tvos_test_wiring.py).
EXTENSION_SHARED_SOURCES = %w[ShelfFetcher.swift ShelfItemMapper.swift ShelfSources.swift].freeze
runner_test_files = Dir.children(RUNNER_TESTS_DIR).reject { |name| name.start_with?('.') }.sort
runner_test_sources = runner_test_files.select { |name| COMPILED_TEST_EXTENSIONS.include?(File.extname(name)) }
raise "No RunnerTests sources found in #{RUNNER_TESTS_DIR}" if runner_test_sources.empty?
@@ -128,6 +133,11 @@ end
extension_target.product_type = 'com.apple.product-type.app-extension'
ensure_source(extension_target, top_shelf_ref)
EXTENSION_SHARED_SOURCES.each do |filename|
shared_ref = ensure_file(extension_group, filename)
ensure_source(extension_target, shared_ref)
ensure_source(runner, shared_ref)
end
removed_framework_refs = []
extension_target.frameworks_build_phase.files.delete_if do |build_file|