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