From 291a22a4a4bec00e332b7211846981c793197be6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:59:16 +0200 Subject: [PATCH] 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 --- .../plezy/watchnext/WatchNextPlugin.kt | 2 +- .../plezy/watchnext/WatchNextProviderTest.kt | 6 +- lib/providers/discover_provider.dart | 17 +- lib/services/system_shelf_service.dart | 107 ++++- test/providers/discover_provider_test.dart | 31 ++ test/services/system_shelf_service_test.dart | 200 ++++++++- tvos/Runner.xcodeproj/project.pbxproj | 33 +- tvos/Runner/SystemShelfPlugin.swift | 148 ++++++- .../TopShelfFetchContractTests.swift | 409 ++++++++++++++++++ tvos/TopShelfExtension/ShelfFetcher.swift | 192 ++++++++ tvos/TopShelfExtension/ShelfItemMapper.swift | 243 +++++++++++ tvos/TopShelfExtension/ShelfSources.swift | 146 +++++++ tvos/TopShelfExtension/TopShelfProvider.swift | 92 +++- tvos/scripts/wire_top_shelf.rb | 10 + 14 files changed, 1605 insertions(+), 31 deletions(-) create mode 100644 tvos/RunnerTests/TopShelfFetchContractTests.swift create mode 100644 tvos/TopShelfExtension/ShelfFetcher.swift create mode 100644 tvos/TopShelfExtension/ShelfItemMapper.swift create mode 100644 tvos/TopShelfExtension/ShelfSources.swift diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt index 1f8f0418..f0e6a725 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt @@ -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? { diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt index 3e53053b..7028e742 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt @@ -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>() diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index bb7b14e6..f6c9a03f 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -82,10 +82,12 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin required this.isProfileBinding, WatchStateStore? watchStateStore, Future Function(String profileId, List)? syncSystemShelf, + Future Function(String profileId, List 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(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 Function(String profileId, List)? _syncSystemShelfOverride; + final Future Function(String profileId, List clients)? _syncServerSourcesOverride; StreamSubscription? _watchStateSubscription; StreamSubscription? _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 _onlineShelfSourceClients() => + _multiServer.serverManager.onlineClients.values.toList(growable: false); + @override void dispose() { _multiServer.removeOnlineServersListener(syncToOnlineServers); diff --git a/lib/services/system_shelf_service.dart b/lib/services/system_shelf_service.dart index 31c63ae1..8cfaee21 100644 --- a/lib/services/system_shelf_service.dart +++ b/lib/services/system_shelf_service.dart @@ -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 Function()? isSupported}) - : _channelOverride = channel, - _supportOverride = isSupported; + SystemShelfService.forTesting({ + required MethodChannel channel, + Future 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 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? 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 syncServerSources(String profileId, List 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(() async { + if (!_owns(profileId, generation)) return false; + return await _invokeGuarded( + 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? _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 (_) { diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index 3613e2b0..2408bfdc 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -363,6 +363,37 @@ void main() { expect(calls, isEmpty); }); + test('each shelf drain pass publishes online server sources before shelf items', () async { + final events = []; + final sourceClients = >[]; + 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')]; diff --git a/test/services/system_shelf_service_test.dart b/test/services/system_shelf_service_test.dart index 274e2c14..29770c54 100644 --- a/test/services/system_shelf_service_test.dart +++ b/test/services/system_shelf_service_test.dart @@ -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 = []; + 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(); + 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 = []; + 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']); + }); } diff --git a/tvos/Runner.xcodeproj/project.pbxproj b/tvos/Runner.xcodeproj/project.pbxproj index 721b04ef..c53695a5 100644 --- a/tvos/Runner.xcodeproj/project.pbxproj +++ b/tvos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterNativeTextInputTests.mm; sourceTree = ""; }; + 5D44F4361594F517C002DC45 /* TopShelfFetchContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfFetchContractTests.swift; sourceTree = ""; }; 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = ""; }; 66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = ""; }; 67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = ""; }; @@ -119,19 +131,21 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9C792B031A97357694F027A0 /* ShelfFetcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShelfFetcher.swift; sourceTree = ""; }; 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = ""; }; A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = ""; }; - 2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MpvPlayerContractTests.swift; sourceTree = ""; }; - A7A0C001A7A0C001A7A0C001 /* AtmosProbeContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtmosProbeContractTests.swift; sourceTree = ""; }; 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 = ""; }; A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = ""; }; A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPluginTests.swift; sourceTree = ""; }; + A7A0C001A7A0C001A7A0C001 /* AtmosProbeContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtmosProbeContractTests.swift; sourceTree = ""; }; B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = ""; }; B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = ""; }; BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = ""; }; + C339C20E52A6F6F6725EF31F /* ShelfItemMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShelfItemMapper.swift; sourceTree = ""; }; D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = ""; }; D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = ""; }; + DC4FE4CC3847E57D0552B366 /* ShelfSources.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShelfSources.swift; sourceTree = ""; }; 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 = ""; }; F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = ""; }; @@ -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; }; diff --git a/tvos/Runner/SystemShelfPlugin.swift b/tvos/Runner/SystemShelfPlugin.swift index 3b212a48..d7329a0f 100644 --- a/tvos/Runner/SystemShelfPlugin.swift +++ b/tvos/Runner/SystemShelfPlugin.swift @@ -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) { for key in removing { let file = ownerDirectory.appendingPathComponent(key, isDirectory: false) diff --git a/tvos/RunnerTests/TopShelfFetchContractTests.swift b/tvos/RunnerTests/TopShelfFetchContractTests.swift new file mode 100644 index 00000000..b1f682ec --- /dev/null +++ b/tvos/RunnerTests/TopShelfFetchContractTests.swift @@ -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 + } +} diff --git a/tvos/TopShelfExtension/ShelfFetcher.swift b/tvos/TopShelfExtension/ShelfFetcher.swift new file mode 100644 index 00000000..ae33d539 --- /dev/null +++ b/tvos/TopShelfExtension/ShelfFetcher.swift @@ -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() + } + } +} diff --git a/tvos/TopShelfExtension/ShelfItemMapper.swift b/tvos/TopShelfExtension/ShelfItemMapper.swift new file mode 100644 index 00000000..70f67e9a --- /dev/null +++ b/tvos/TopShelfExtension/ShelfItemMapper.swift @@ -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[.. [ShelfFetchedItem] { + var seen = Set() + 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) } + } +} diff --git a/tvos/TopShelfExtension/ShelfSources.swift b/tvos/TopShelfExtension/ShelfSources.swift new file mode 100644 index 00000000..58228e57 --- /dev/null +++ b/tvos/TopShelfExtension/ShelfSources.swift @@ -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 } + } +} diff --git a/tvos/TopShelfExtension/TopShelfProvider.swift b/tvos/TopShelfExtension/TopShelfProvider.swift index 2a73be31..73b78b0c 100644 --- a/tvos/TopShelfExtension/TopShelfProvider.swift +++ b/tvos/TopShelfExtension/TopShelfProvider.swift @@ -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)" } diff --git a/tvos/scripts/wire_top_shelf.rb b/tvos/scripts/wire_top_shelf.rb index 7a84ed96..02953265 100644 --- a/tvos/scripts/wire_top_shelf.rb +++ b/tvos/scripts/wire_top_shelf.rb @@ -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|