diff --git a/app.json b/app.json index 5974f6f1..d4699bf9 100644 --- a/app.json +++ b/app.json @@ -140,6 +140,7 @@ ["./plugins/withTrustLocalCerts.js"], ["./plugins/withGradleProperties.js"], ["./plugins/withTVOSAppIcon.js"], + ["./plugins/withTVOSTopShelf.js"], ["./plugins/withTVXcodeEnv.js"], [ "./plugins/withGitPod.js", diff --git a/app/topshelf/item.tsx b/app/topshelf/item.tsx new file mode 100644 index 00000000..6f93cf5b --- /dev/null +++ b/app/topshelf/item.tsx @@ -0,0 +1,33 @@ +import { useLocalSearchParams, useRootNavigationState } from "expo-router"; +import { useEffect } from "react"; +import { View } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; + +export default function TopShelfItemRedirect() { + const router = useRouter(); + const rootNavigationState = useRootNavigationState(); + const { id, type } = useLocalSearchParams<{ + id?: string; + type?: string; + }>(); + + useEffect(() => { + if (!rootNavigationState?.key) { + return; + } + + if (!id) { + router.replace("/(auth)/(tabs)/(home)"); + return; + } + + if (type === "Series") { + router.replace(`/(auth)/(tabs)/(home)/series/${id}`); + return; + } + + router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`); + }, [id, rootNavigationState?.key, router, type]); + + return ; +} diff --git a/app/topshelf/play.tsx b/app/topshelf/play.tsx new file mode 100644 index 00000000..5b848d27 --- /dev/null +++ b/app/topshelf/play.tsx @@ -0,0 +1,32 @@ +import { useLocalSearchParams, useRootNavigationState } from "expo-router"; +import { useEffect } from "react"; +import { View } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; + +export default function TopShelfPlayRedirect() { + const router = useRouter(); + const rootNavigationState = useRootNavigationState(); + const { id } = useLocalSearchParams<{ + id?: string; + }>(); + + useEffect(() => { + if (!rootNavigationState?.key) { + return; + } + + if (!id) { + router.replace("/(auth)/(tabs)/(home)"); + return; + } + + const queryParams = new URLSearchParams({ + itemId: id, + offline: "false", + }); + + router.replace(`/player/direct-player?${queryParams.toString()}`); + }, [id, rootNavigationState?.key, router]); + + return ; +} diff --git a/biome.json b/biome.json index 6f51bd9b..67ed64e0 100644 --- a/biome.json +++ b/biome.json @@ -8,6 +8,8 @@ "!android", "!Streamyfin.app", "!utils/jellyseerr", + "!expo-env.d.ts", + "!modules/**/android/build", "!.expo", "!docs/jellyfin-openapi-stable.json" ] diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index f2318db8..482bdc61 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -41,6 +41,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { scaleSize } from "@/utils/scaleSize"; +import { updateTopShelfCache } from "@/utils/topshelf/cache"; const HORIZONTAL_PADDING = scaleSize(60); const TOP_PADDING = scaleSize(100); @@ -258,6 +259,18 @@ export const Home = () => { refetchInterval: 60 * 1000, }); + useEffect(() => { + updateTopShelfCache({ + api, + sections: [ + { + title: t("home.continue_and_next_up"), + items: heroItems, + }, + ], + }); + }, [api, heroItems, t]); + const userViews = useMemo( () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), [data, settings?.hiddenLibraries], diff --git a/modules/index.ts b/modules/index.ts index d93e9077..6e27ca00 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -24,3 +24,10 @@ export type { VideoSource as MpvVideoSource, } from "./mpv-player"; export { MpvPlayerView } from "./mpv-player"; +// Top Shelf cache (tvOS) +export type { + TopShelfCacheItem, + TopShelfCachePayload, + TopShelfCacheSection, +} from "./top-shelf-cache"; +export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache"; diff --git a/modules/top-shelf-cache/expo-module.config.json b/modules/top-shelf-cache/expo-module.config.json new file mode 100644 index 00000000..2c34c1f3 --- /dev/null +++ b/modules/top-shelf-cache/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["TopShelfCacheModule"] + } +} diff --git a/modules/top-shelf-cache/index.ts b/modules/top-shelf-cache/index.ts new file mode 100644 index 00000000..3bd16e17 --- /dev/null +++ b/modules/top-shelf-cache/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/modules/top-shelf-cache/ios/TopShelfCache.podspec b/modules/top-shelf-cache/ios/TopShelfCache.podspec new file mode 100644 index 00000000..aac20241 --- /dev/null +++ b/modules/top-shelf-cache/ios/TopShelfCache.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'TopShelfCache' + s.version = '1.0.0' + s.summary = 'Shared Top Shelf cache writer for Streamyfin tvOS' + s.description = 'Writes lightweight Top Shelf cache payloads to an App Group container for the tvOS extension.' + s.author = 'Streamyfin' + s.homepage = 'https://github.com/streamyfin/streamyfin' + s.platforms = { + :ios => '15.1', + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_VERSION' => '5.9' + } + + s.source_files = "*.{h,m,mm,swift}" +end diff --git a/modules/top-shelf-cache/ios/TopShelfCacheModule.swift b/modules/top-shelf-cache/ios/TopShelfCacheModule.swift new file mode 100644 index 00000000..12e33932 --- /dev/null +++ b/modules/top-shelf-cache/ios/TopShelfCacheModule.swift @@ -0,0 +1,112 @@ +import ExpoModulesCore +import Foundation +import Security +#if canImport(TVServices) +import TVServices +#endif + +public class TopShelfCacheModule: Module { + private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier" + private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier" + private let cacheKey = "TopShelfCache" + private let apiKeyService = "StreamyfinTopShelf" + private let apiKeyAccount = "JellyfinApiKey" + private var appGroupIdentifier: String? { + if let appGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: appGroupInfoPlistKey + ) as? String { + return appGroupIdentifier + } + + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + return nil + } + + return "group.\(bundleIdentifier)" + } + private var keychainAccessGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: keychainAccessGroupInfoPlistKey) as? String + } + + public func definition() -> ModuleDefinition { + Name("TopShelfCache") + + Function("writeCache") { (json: String, apiKey: String?) -> Bool in + guard + let appGroupIdentifier = appGroupIdentifier, + let defaults = UserDefaults(suiteName: appGroupIdentifier) + else { + return false + } + + defaults.set(json, forKey: cacheKey) + defaults.set(Date().timeIntervalSince1970, forKey: "\(cacheKey)UpdatedAt") + defaults.synchronize() + let didSaveAPIKey = saveAPIKey(apiKey) + + #if canImport(TVServices) + TVTopShelfContentProvider.topShelfContentDidChange() + #endif + + return didSaveAPIKey + } + + Function("clearCache") { () -> Bool in + guard + let appGroupIdentifier = appGroupIdentifier, + let defaults = UserDefaults(suiteName: appGroupIdentifier) + else { + return false + } + + defaults.removeObject(forKey: cacheKey) + defaults.removeObject(forKey: "\(cacheKey)UpdatedAt") + defaults.synchronize() + let didDeleteAPIKey = deleteAPIKey() + + #if canImport(TVServices) + TVTopShelfContentProvider.topShelfContentDidChange() + #endif + + return didDeleteAPIKey + } + } + + private func baseAPIKeyQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: apiKeyService, + kSecAttrAccount as String: apiKeyAccount + ] + + if let keychainAccessGroupIdentifier { + query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier + } + + return query + } + + private func saveAPIKey(_ apiKey: String?) -> Bool { + guard deleteAPIKey() else { + return false + } + + guard + let apiKey, + !apiKey.isEmpty, + let data = apiKey.data(using: .utf8) + else { + return true + } + + var query = baseAPIKeyQuery() + query[kSecValueData as String] = data + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + private func deleteAPIKey() -> Bool { + let status = SecItemDelete(baseAPIKeyQuery() as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/modules/top-shelf-cache/src/TopShelfCache.types.ts b/modules/top-shelf-cache/src/TopShelfCache.types.ts new file mode 100644 index 00000000..7888d7b9 --- /dev/null +++ b/modules/top-shelf-cache/src/TopShelfCache.types.ts @@ -0,0 +1,21 @@ +export type TopShelfCacheModuleEvents = Record; + +export interface TopShelfCacheItem { + id: string; + title: string; + subtitle?: string; + imageUrl?: string; + route: string; + playRoute?: string; +} + +export interface TopShelfCacheSection { + title: string; + items: TopShelfCacheItem[]; +} + +export interface TopShelfCachePayload { + version: 1; + updatedAt: string; + sections: TopShelfCacheSection[]; +} diff --git a/modules/top-shelf-cache/src/TopShelfCacheModule.ts b/modules/top-shelf-cache/src/TopShelfCacheModule.ts new file mode 100644 index 00000000..c6692c03 --- /dev/null +++ b/modules/top-shelf-cache/src/TopShelfCacheModule.ts @@ -0,0 +1,37 @@ +import { NativeModule, requireNativeModule } from "expo"; +import { Platform } from "react-native"; +import type { TopShelfCacheModuleEvents } from "./TopShelfCache.types"; + +declare class TopShelfCacheModuleType extends NativeModule { + writeCache(json: string, apiKey?: string): boolean; + clearCache(): boolean; +} + +let TopShelfCacheNativeModule: TopShelfCacheModuleType | null = null; + +if (Platform.OS === "ios" && Platform.isTV) { + try { + TopShelfCacheNativeModule = + requireNativeModule("TopShelfCache"); + } catch { + TopShelfCacheNativeModule = null; + } +} + +export function writeTopShelfCache(json: string, apiKey?: string): boolean { + if (!TopShelfCacheNativeModule) return false; + + try { + return TopShelfCacheNativeModule.writeCache(json, apiKey); + } catch { + try { + return TopShelfCacheNativeModule.writeCache(json); + } catch { + return false; + } + } +} + +export function clearTopShelfCache(): boolean { + return TopShelfCacheNativeModule?.clearCache() ?? false; +} diff --git a/modules/top-shelf-cache/src/index.ts b/modules/top-shelf-cache/src/index.ts new file mode 100644 index 00000000..9f180ab7 --- /dev/null +++ b/modules/top-shelf-cache/src/index.ts @@ -0,0 +1,2 @@ +export * from "./TopShelfCache.types"; +export { clearTopShelfCache, writeTopShelfCache } from "./TopShelfCacheModule"; diff --git a/plugins/withTVOSTopShelf.js b/plugins/withTVOSTopShelf.js new file mode 100644 index 00000000..b3705134 --- /dev/null +++ b/plugins/withTVOSTopShelf.js @@ -0,0 +1,196 @@ +const { + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} = require("@expo/config-plugins"); + +const EXTENSION_TARGET_NAME = "StreamyfinTopShelf"; +const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf"; +const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier"; +const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY = + "StreamyfinKeychainAccessGroupIdentifier"; + +function getBundleIdentifier(config) { + return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin"; +} + +function getAppGroupIdentifier(config) { + return `group.${getBundleIdentifier(config)}`; +} + +function getKeychainAccessGroupIdentifier(config) { + return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`; +} + +function getBuildConfigurations(project, configurationListId) { + const configurationList = + project.hash.project.objects.XCConfigurationList[configurationListId]; + + if (!configurationList?.buildConfigurations) return []; + + const buildConfigurations = project.pbxXCBuildConfigurationSection(); + return configurationList.buildConfigurations + .map((config) => buildConfigurations[config.value]) + .filter(Boolean); +} + +function ensureAppGroup(value, appGroupIdentifier) { + const groups = Array.isArray(value) ? value : []; + return groups.includes(appGroupIdentifier) + ? groups + : [...groups, appGroupIdentifier]; +} + +function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { + const groups = Array.isArray(value) ? value : []; + return groups.includes(keychainAccessGroupIdentifier) + ? groups + : [...groups, keychainAccessGroupIdentifier]; +} + +function ensureAppExtension( + appExtensions, + targetName, + bundleIdentifier, + appGroupIdentifier, + keychainAccessGroupIdentifier, +) { + const extensionConfig = { + targetName, + bundleIdentifier, + entitlements: { + "com.apple.security.application-groups": [appGroupIdentifier], + "keychain-access-groups": [keychainAccessGroupIdentifier], + }, + }; + const extensions = Array.isArray(appExtensions) ? appExtensions : []; + // Keep plugin runs idempotent and preserve unrelated app extension entries. + const existingIndex = extensions.findIndex( + (appExtension) => appExtension?.targetName === targetName, + ); + + if (existingIndex === -1) { + return [...extensions, extensionConfig]; + } + + return extensions.map((appExtension, index) => + index === existingIndex ? extensionConfig : appExtension, + ); +} + +const withTVOSTopShelf = (config) => { + const appGroupIdentifier = getAppGroupIdentifier(config); + const keychainAccessGroupIdentifier = + getKeychainAccessGroupIdentifier(config); + const bundleIdentifier = getBundleIdentifier(config); + const extensionBundleIdentifier = `${bundleIdentifier}.TopShelf`; + const isTVBuild = process.env.EXPO_TV === "1"; + + if (isTVBuild) { + config.extra = { + ...config.extra, + eas: { + ...config.extra?.eas, + build: { + ...config.extra?.eas?.build, + experimental: { + ...config.extra?.eas?.build?.experimental, + ios: { + ...config.extra?.eas?.build?.experimental?.ios, + appExtensions: ensureAppExtension( + config.extra?.eas?.build?.experimental?.ios?.appExtensions, + EXTENSION_TARGET_NAME, + extensionBundleIdentifier, + appGroupIdentifier, + keychainAccessGroupIdentifier, + ), + }, + }, + }, + }, + }; + + config = withInfoPlist(config, (config) => { + config.modResults[APP_GROUP_INFO_PLIST_KEY] = appGroupIdentifier; + config.modResults[KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY] = + keychainAccessGroupIdentifier; + return config; + }); + + config = withEntitlementsPlist(config, (config) => { + config.modResults["com.apple.security.application-groups"] = + ensureAppGroup( + config.modResults["com.apple.security.application-groups"], + appGroupIdentifier, + ); + config.modResults["keychain-access-groups"] = ensureKeychainAccessGroup( + config.modResults["keychain-access-groups"], + keychainAccessGroupIdentifier, + ); + return config; + }); + } + + if (!isTVBuild) { + return config; + } + + return withXcodeProject(config, (config) => { + const project = config.modResults; + + if (project.pbxTargetByName(EXTENSION_TARGET_NAME)) { + return config; + } + + const target = project.addTarget( + EXTENSION_TARGET_NAME, + "app_extension", + EXTENSION_TARGET_NAME, + extensionBundleIdentifier, + ); + + project.addBuildPhase( + [`${TARGET_SOURCE_DIR}/TopShelfProvider.swift`], + "PBXSourcesBuildPhase", + "Sources", + target.uuid, + ); + project.addBuildPhase( + ["TVServices.framework"], + "PBXFrameworksBuildPhase", + "Frameworks", + target.uuid, + ); + + const buildConfigurations = getBuildConfigurations( + project, + target.pbxNativeTarget.buildConfigurationList, + ); + + for (const buildConfig of buildConfigurations) { + buildConfig.buildSettings = { + ...buildConfig.buildSettings, + CODE_SIGN_ENTITLEMENTS: `${TARGET_SOURCE_DIR}/${EXTENSION_TARGET_NAME}.entitlements`, + APPLICATION_EXTENSION_API_ONLY: "YES", + CURRENT_PROJECT_VERSION: "1", + INFOPLIST_FILE: `${TARGET_SOURCE_DIR}/Info.plist`, + IPHONEOS_DEPLOYMENT_TARGET: + buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET || "15.6", + MARKETING_VERSION: config.version || "1.0", + PRODUCT_BUNDLE_IDENTIFIER: extensionBundleIdentifier, + PRODUCT_NAME: `"${EXTENSION_TARGET_NAME}"`, + SDKROOT: "appletvos", + SKIP_INSTALL: "YES", + SWIFT_VERSION: "5.9", + APP_GROUP_IDENTIFIER: appGroupIdentifier, + KEYCHAIN_ACCESS_GROUP_IDENTIFIER: `"${keychainAccessGroupIdentifier}"`, + SUPPORTED_PLATFORMS: '"appletvos appletvsimulator"', + TARGETED_DEVICE_FAMILY: "3", + }; + } + + return config; + }); +}; + +module.exports = withTVOSTopShelf; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index ebdaaca1..2007de1c 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -39,6 +39,7 @@ import { updateAccountToken, } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; +import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache"; interface Server { address: string; @@ -232,6 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const setServerMutation = useMutation({ mutationFn: async (server: Server) => { + clearTopShelfCacheSafely(); const apiInstance = jellyfin?.createApi(server.address); if (!apiInstance?.basePath) throw new Error("Failed to connect"); @@ -250,6 +252,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const removeServerMutation = useMutation({ mutationFn: async () => { + clearTopShelfCacheSafely(); storage.remove("serverUrl"); setApi(null); }, @@ -361,6 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ); storage.remove("token"); + clearTopShelfCacheSafely(); setUser(null); setApi(null); setPluginSettings(undefined); @@ -531,6 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ (newUrl: string) => { if (!jellyfin || !api?.accessToken) return; + clearTopShelfCacheSafely(); const newApi = jellyfin.createApi(newUrl, api.accessToken); setApi(newApi); // Note: We don't update storage.set("serverUrl") here @@ -661,10 +666,11 @@ function useProtectedRoute(user: UserDto | null, loaded = false) { if (loaded === false) return; const inAuthGroup = segments.length > 1 && segments[0] === "(auth)"; + const isTopShelfLaunchRoute = segments[0] === "topshelf"; if (!user?.Id && inAuthGroup) { router.replace("/login"); - } else if (user?.Id && !inAuthGroup) { + } else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) { router.replace("/(auth)/(tabs)/(home)/"); } }, [user, segments, loaded]); diff --git a/targets/StreamyfinTopShelf/Info.plist b/targets/StreamyfinTopShelf/Info.plist new file mode 100644 index 00000000..184b8d3d --- /dev/null +++ b/targets/StreamyfinTopShelf/Info.plist @@ -0,0 +1,40 @@ + + + + + StreamyfinAppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + StreamyfinKeychainAccessGroupIdentifier + $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleDisplayName + Streamyfin Top Shelf + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.tv-top-shelf + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).TopShelfProvider + + + diff --git a/targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements b/targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements new file mode 100644 index 00000000..d29ed85f --- /dev/null +++ b/targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) + + + diff --git a/targets/StreamyfinTopShelf/TopShelfProvider.swift b/targets/StreamyfinTopShelf/TopShelfProvider.swift new file mode 100644 index 00000000..ee73685d --- /dev/null +++ b/targets/StreamyfinTopShelf/TopShelfProvider.swift @@ -0,0 +1,125 @@ +import Foundation +import Security +import TVServices + +private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier" +private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier" +private let cacheKey = "TopShelfCache" +private let apiKeyService = "StreamyfinTopShelf" +private let apiKeyAccount = "JellyfinApiKey" + +private struct TopShelfCachePayload: Decodable { + let sections: [TopShelfCacheSection] +} + +private struct TopShelfCacheSection: Decodable { + let title: String + let items: [TopShelfCacheItem] +} + +private struct TopShelfCacheItem: Decodable { + let id: String + let title: String + let imageUrl: String? + let route: String + let playRoute: String? +} + +final class TopShelfProvider: TVTopShelfContentProvider { + override func loadTopShelfContent( + completionHandler: @escaping (TVTopShelfContent?) -> Void + ) { + guard + let appGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: appGroupInfoPlistKey + ) as? String, + let defaults = UserDefaults(suiteName: appGroupIdentifier), + let json = defaults.string(forKey: cacheKey), + let data = json.data(using: .utf8), + let payload = try? JSONDecoder().decode(TopShelfCachePayload.self, from: data) + else { + completionHandler(nil) + return + } + + let apiKey = readAPIKey() + let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection? in + let items = section.items.compactMap { makeTopShelfItem($0, apiKey: apiKey) } + guard !items.isEmpty else { return nil } + + let collection = TVTopShelfItemCollection(items: items) + collection.title = section.title + return collection + } + + completionHandler(sections.isEmpty ? nil : TVTopShelfSectionedContent(sections: sections)) + } + + private func makeTopShelfItem( + _ cacheItem: TopShelfCacheItem, + apiKey: String? + ) -> TVTopShelfSectionedItem? { + guard let route = URL(string: cacheItem.route) else { + return nil + } + + let item = TVTopShelfSectionedItem(identifier: cacheItem.id) + item.title = cacheItem.title + item.imageShape = .poster + item.displayAction = TVTopShelfAction(url: route) + + if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) { + item.playAction = TVTopShelfAction(url: playURL) + } + + if let imageUrl = cacheItem.imageUrl, + let url = imageURL(from: imageUrl, apiKey: apiKey) { + item.setImageURL(url, for: .screenScale1x) + item.setImageURL(url, for: .screenScale2x) + } + + return item + } + + private func imageURL(from imageUrl: String, apiKey: String?) -> URL? { + guard var components = URLComponents(string: imageUrl) else { + return nil + } + + if let apiKey, !apiKey.isEmpty { + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "api_key" } + queryItems.append(URLQueryItem(name: "api_key", value: apiKey)) + components.queryItems = queryItems + } + + return components.url + } + + private func readAPIKey() -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: apiKeyService, + kSecAttrAccount as String: apiKeyAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + if let keychainAccessGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: keychainAccessGroupInfoPlistKey + ) as? String { + query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier + } + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard + status == errSecSuccess, + let data = item as? Data + else { + return nil + } + + return String(data: data, encoding: .utf8) + } +} diff --git a/utils/topshelf/cache.ts b/utils/topshelf/cache.ts new file mode 100644 index 00000000..867797fd --- /dev/null +++ b/utils/topshelf/cache.ts @@ -0,0 +1,160 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Platform } from "react-native"; +import { + clearTopShelfCache, + type TopShelfCachePayload, + type TopShelfCacheSection, + writeTopShelfCache, +} from "@/modules"; + +const TOP_SHELF_ITEM_LIMIT = 12; + +function getTopShelfImageUrl(item: BaseItemDto, api: Api): string | undefined { + const baseUrl = api.basePath; + + if (item.Type === "Episode") { + if (item.SeriesId && item.SeriesPrimaryImageTag) { + return `${baseUrl}/Items/${item.SeriesId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.SeriesPrimaryImageTag)}&width=500`; + } + + if (item.ParentPrimaryImageItemId && item.ParentPrimaryImageTag) { + return `${baseUrl}/Items/${item.ParentPrimaryImageItemId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.ParentPrimaryImageTag)}&width=500`; + } + } + + const primaryTag = item.ImageTags?.Primary; + if (item.Id && primaryTag) { + return `${baseUrl}/Items/${item.Id}/Images/Primary?quality=90&tag=${encodeURIComponent(primaryTag)}&width=500`; + } + + const backdropTag = item.BackdropImageTags?.[0]; + if (item.Id && backdropTag) { + return `${baseUrl}/Items/${item.Id}/Images/Backdrop/0?quality=90&tag=${encodeURIComponent(backdropTag)}&width=800`; + } + + return undefined; +} + +function formatEpisodeNumber(item: BaseItemDto): string | undefined { + const season = item.ParentIndexNumber; + const episode = item.IndexNumber; + + if (season != null && episode != null) { + return `S${season} • E${episode}`; + } + + if (season != null) return `Season ${season}`; + if (episode != null) return `Episode ${episode}`; + + return undefined; +} + +function getTopShelfTitle(item: BaseItemDto): string { + if (item.Type === "Episode") { + const episodeNumber = formatEpisodeNumber(item); + + if (item.SeriesName && episodeNumber) { + return `${item.SeriesName} - ${episodeNumber}`; + } + + if (item.SeriesName) return item.SeriesName; + if (episodeNumber) return episodeNumber; + return item.Name || ""; + } + + return item.Name || ""; +} + +function getTopShelfSubtitle(item: BaseItemDto): string | undefined { + if (item.Type === "Episode") return undefined; + + return item.ProductionYear ? String(item.ProductionYear) : item.Type; +} + +function sectionFromItems( + title: string, + items: BaseItemDto[] | undefined, + api: Api, +): TopShelfCacheSection | null { + const cacheItems = (items || []) + .filter((item) => item.Id && item.Name) + .slice(0, TOP_SHELF_ITEM_LIMIT) + .map((item) => ({ + id: item.Id!, + title: getTopShelfTitle(item), + subtitle: getTopShelfSubtitle(item), + imageUrl: getTopShelfImageUrl(item, api), + route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, + playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, + })); + + if (cacheItems.length === 0) return null; + + return { + title, + items: cacheItems, + }; +} + +export function updateTopShelfCache({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + if (!api) { + clearTopShelfCacheSafely(); + return; + } + + const payloadSections = sections + .map((section) => sectionFromItems(section.title, section.items, api)) + .filter((section): section is TopShelfCacheSection => section !== null) + .slice(0, 3); + + if (payloadSections.length === 0) { + clearTopShelfCacheSafely(); + return; + } + + const payload: TopShelfCachePayload = { + version: 1, + updatedAt: new Date().toISOString(), + sections: payloadSections, + }; + + try { + const didWrite = writeTopShelfCache( + JSON.stringify(payload), + api.accessToken || undefined, + ); + + if (__DEV__ && !didWrite) { + console.warn("[TopShelf] Native cache writer is unavailable"); + } + } catch (error) { + if (__DEV__) { + console.warn("[TopShelf] Failed to write cache", error); + } + } +} + +export function clearTopShelfCacheSafely(): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + try { + const didClear = clearTopShelfCache(); + + if (__DEV__ && !didClear) { + console.warn("[TopShelf] Native cache clearer is unavailable"); + } + } catch (error) { + if (__DEV__) { + console.warn("[TopShelf] Failed to clear cache", error); + } + } +}