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
+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|