diff --git a/README.md b/README.md index b5b418d21..258005ef7 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`. `npm run prebuild:tv` `npm run ios:tv or npm run android:tv` +TV platform integration notes: + +- [TV Discovery](./docs/tv-discovery.md) + ## 👋 Get in Touch with Us Need assistance or have any questions? diff --git a/app.json b/app.json index 5974f6f1b..d4699bf92 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 000000000..6f93cf5b7 --- /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 000000000..5b848d27a --- /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 6f51bd9b0..67ed64e02 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 f2318db8c..cffff54fa 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 { updateTVDiscovery } from "@/utils/tvDiscovery/sync"; const HORIZONTAL_PADDING = scaleSize(60); const TOP_PADDING = scaleSize(100); @@ -258,6 +259,18 @@ export const Home = () => { refetchInterval: 60 * 1000, }); + useEffect(() => { + updateTVDiscovery({ + 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/docs/tv-discovery.md b/docs/tv-discovery.md new file mode 100644 index 000000000..b1a551656 --- /dev/null +++ b/docs/tv-discovery.md @@ -0,0 +1,136 @@ +# TV Discovery + +This document explains Streamyfin's platform-specific home screen discovery integrations for Apple TV and Android TV. + +## Overview + +Streamyfin currently publishes the same "Continue and Next Up" content to two different platform surfaces: + +- `tvOS`: Apple TV Top Shelf +- `Android TV`: preview channel recommendations + +Both integrations are fed by the same shared payload builder in [utils/tvDiscovery/payload.ts](../utils/tvDiscovery/payload.ts). + +## Shared Data Flow + +The TV home screen data starts in [components/home/Home.tv.tsx](../components/home/Home.tv.tsx), where the app fetches resume and next-up items and passes them into [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts). + +The sync layer: + +- builds a normalized TV discovery payload +- sends it to the tvOS Top Shelf cache writer on Apple TV +- sends it to the Android TV recommendations module on Android TV +- clears published content when server or user state changes + +## Apple TV Top Shelf + +Apple TV uses a Top Shelf extension target, not the main app process. + +Relevant files: + +- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) +- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) +- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) +- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) + +How it works: + +- the app builds a lightweight JSON payload +- the app stores that payload in the shared app group container +- the tvOS Top Shelf extension reads the cached payload +- the extension renders sections and items for Top Shelf + +Why the API key is stored on tvOS: + +- the Top Shelf extension runs outside the app process +- it may need authenticated image access when loading poster artwork +- the app stores the API key so the extension can build authenticated requests + +## Android TV Recommendations + +Android TV uses the TV provider APIs to publish a preview channel and preview programs. + +Relevant files: + +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt) +- [modules/tv-recommendations/android/src/main/AndroidManifest.xml](../modules/tv-recommendations/android/src/main/AndroidManifest.xml) +- [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts) + +How it works: + +- the app builds the shared TV discovery payload +- the Android native module creates or updates a single preview channel +- the module inserts or updates preview programs for each item +- the module stores the last payload in shared preferences +- the `INITIALIZE_PROGRAMS` receiver can replay the cached payload when requested by the system + +Important differences from tvOS: + +- Android TV does not use a separate extension target +- Android TV content is persisted through `TvContractCompat` +- artwork is currently published as poster URLs, not app-proxied local content + +## Logging + +### JavaScript logs + +Look for `TVDiscovery` in Metro or app logs. + +Examples: + +- payload prepared +- Android sync result +- clear operations + +### Native Android logs + +Use `adb logcat | grep TvRecommendations` + +Examples: + +- channel created or updated +- preview programs inserted or updated +- stale programs deleted +- cached payload replayed + +## Verifying Android TV Output + +1. Launch the TV build and let the home screen load. +2. Watch `adb logcat | grep TvRecommendations`. +3. Return to the Android TV / Google TV home screen. +4. Look for the `Continue and Next Up` row. +5. If needed, enable the Streamyfin channel in `Customize home` or `Manage channels`. + +Note: + +- some launchers delay or hide new preview channels +- some devices expose TV provider data per user/profile + +## Build Notes + +This feature does not currently require a fresh `prebuild` to work in the checked-in Android project. + +Why: + +- the Android integration is a local Expo module +- its receiver is declared in the module manifest +- Gradle merges it during normal Android TV builds + +Typical commands: + +- `bun run android:tv` +- `bun run ios:tv` + +## Current Limitations + +- Android TV artwork may fail on authenticated Jellyfin servers because the launcher fetches poster URLs outside the app +- Android TV currently publishes a preview channel only, not Watch Next +- tvOS and Android TV both use the same payload source, so section selection is shared unless explicitly split later + +## Future Improvements + +- add a local image proxy or cache for Android TV artwork +- add Watch Next support for resumable content +- add a native debug dump method for querying TV provider state from inside the app process diff --git a/modules/index.ts b/modules/index.ts index d93e9077a..1f2b458f0 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -24,3 +24,16 @@ 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"; +// TV recommendations (Android TV) +export { + clearTvRecommendations, + refreshTvRecommendations, + syncTvRecommendations, +} from "./tv-recommendations"; 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 000000000..2c34c1f35 --- /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 000000000..3bd16e178 --- /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 000000000..aac202417 --- /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 000000000..12e339321 --- /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 000000000..7888d7b95 --- /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 000000000..c6692c030 --- /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 000000000..9f180ab71 --- /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/modules/tv-recommendations/android/build.gradle b/modules/tv-recommendations/android/build.gradle new file mode 100644 index 000000000..b9692ba0a --- /dev/null +++ b/modules/tv-recommendations/android/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +group = 'expo.modules.tvrecommendations' +version = '1.0.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' + +apply from: expoModulesCorePlugin + +applyKotlinExpoModulesCorePlugin() +useDefaultAndroidSdkVersions() +useCoreDependencies() +useExpoPublishing() + +android { + namespace "expo.modules.tvrecommendations" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + lintOptions { + abortOnError false + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "androidx.tvprovider:tvprovider:1.1.0" + implementation "androidx.core:core-ktx:1.13.1" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "17" + } +} diff --git a/modules/tv-recommendations/android/src/main/AndroidManifest.xml b/modules/tv-recommendations/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..87a7944c0 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt new file mode 100644 index 000000000..11b29c397 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt @@ -0,0 +1,25 @@ +package expo.modules.tvrecommendations + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class TvRecommendationsModule : Module() { + override fun definition() = ModuleDefinition { + Name("TvRecommendations") + + Function("syncRecommendations") { json: String -> + val context = appContext.reactContext ?: return@Function false + TvRecommendationsPublisher.sync(context, json) + } + + Function("clearRecommendations") { + val context = appContext.reactContext ?: return@Function false + TvRecommendationsPublisher.clear(context) + } + + Function("refreshRecommendations") { + val context = appContext.reactContext ?: return@Function false + TvRecommendationsPublisher.refreshFromCache(context) + } + } +} diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt new file mode 100644 index 000000000..349d89de8 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt @@ -0,0 +1,378 @@ +package expo.modules.tvrecommendations + +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.Log +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import org.json.JSONArray +import org.json.JSONObject + +internal object TvRecommendationsPublisher { + private const val TAG = "TvRecommendations" + private const val PREFS_NAME = "StreamyfinTvRecommendations" + private const val KEY_PAYLOAD = "payload" + private const val KEY_CHANNEL_ID = "channelId" + private const val KEY_PROGRAM_IDS = "programIds" + private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up" + + fun sync(context: Context, payloadJson: String): Boolean { + val payload = try { + JSONObject(payloadJson) + } catch (error: Exception) { + Log.e(TAG, "Failed to parse recommendations payload", error) + return false + } + + val sectionCount = payload.optJSONArray("sections")?.length() ?: 0 + Log.d(TAG, "sync(): received payload with $sectionCount section(s)") + + preferences(context) + .edit() + .putString(KEY_PAYLOAD, payloadJson) + .apply() + + return synchronize(context, payload) + } + + fun refreshFromCache(context: Context): Boolean { + val payloadJson = preferences(context).getString(KEY_PAYLOAD, null) ?: return false + val payload = try { + JSONObject(payloadJson) + } catch (error: Exception) { + Log.e(TAG, "Failed to parse cached recommendations payload", error) + return false + } + + val sectionCount = payload.optJSONArray("sections")?.length() ?: 0 + Log.d(TAG, "refreshFromCache(): replaying cached payload with $sectionCount section(s)") + + return synchronize(context, payload) + } + + fun clear(context: Context): Boolean { + val prefs = preferences(context) + val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L) + val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject) + val contentResolver = context.contentResolver + + if (programIds != null) { + var deletedPrograms = 0 + val keys = programIds.keys() + while (keys.hasNext()) { + val key = keys.next() + val programId = programIds.optLong(key, -1L) + if (programId > 0L) { + contentResolver.delete( + TvContractCompat.buildPreviewProgramUri(programId), + null, + null + ) + deletedPrograms += 1 + } + } + Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)") + } + + if (channelId > 0L) { + contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null) + Log.d(TAG, "clear(): notified channel $channelId") + } + + prefs.edit() + .remove(KEY_PAYLOAD) + .remove(KEY_PROGRAM_IDS) + .apply() + + return true + } + + private fun synchronize(context: Context, payload: JSONObject): Boolean { + val sections = payload.optJSONArray("sections") ?: JSONArray() + val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null + val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME + val items = firstSection?.optJSONArray("items") ?: JSONArray() + + Log.d( + TAG, + "synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)" + ) + + val channelId = getOrCreateChannel(context, sectionTitle) + if (channelId <= 0L) { + Log.w(TAG, "synchronize(): failed to get or create preview channel") + return false + } + + Log.d(TAG, "synchronize(): publishing into channelId=$channelId") + + val previousProgramIds = preferences(context) + .getString(KEY_PROGRAM_IDS, null) + ?.let(::JSONObject) + ?: JSONObject() + val nextProgramIds = JSONObject() + val activeProviderIds = mutableSetOf() + + for (index in 0 until items.length()) { + val item = items.optJSONObject(index) ?: continue + val providerId = item.optString("id") + if (providerId.isBlank()) continue + + val programId = upsertPreviewProgram( + context = context, + channelId = channelId, + item = item, + previousProgramId = previousProgramIds.optLong(providerId, -1L), + weight = index + ) + + if (programId > 0L) { + activeProviderIds += providerId + nextProgramIds.put(providerId, programId) + Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId") + } + } + + var deletedPrograms = 0 + val previousKeys = previousProgramIds.keys() + while (previousKeys.hasNext()) { + val providerId = previousKeys.next() + if (activeProviderIds.contains(providerId)) continue + + val programId = previousProgramIds.optLong(providerId, -1L) + if (programId > 0L) { + context.contentResolver.delete( + TvContractCompat.buildPreviewProgramUri(programId), + null, + null + ) + deletedPrograms += 1 + Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId") + } + } + + preferences(context) + .edit() + .putLong(KEY_CHANNEL_ID, channelId) + .putString(KEY_PROGRAM_IDS, nextProgramIds.toString()) + .apply() + + logProviderState(context, channelId) + + Log.d( + TAG, + "synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)" + ) + + return true + } + + private fun getOrCreateChannel(context: Context, displayName: String): Long { + val prefs = preferences(context) + val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) + val contentResolver = context.contentResolver + + if (existingChannelId > 0L) { + val updated = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(displayName) + .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) + .build() + + val updatedRows = contentResolver.update( + TvContractCompat.buildChannelUri(existingChannelId), + updated.toContentValues(), + null, + null + ) + + if (updatedRows > 0) { + TvContractCompat.requestChannelBrowsable(context, existingChannelId) + storeChannelLogo(context, existingChannelId) + Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable") + return existingChannelId + } + + Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating") + prefs.edit().remove(KEY_CHANNEL_ID).apply() + } + + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(displayName) + .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) + .build() + + val channelUri = contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) ?: return -1L + + val channelId = ContentUris.parseId(channelUri) + TvContractCompat.requestChannelBrowsable(context, channelId) + storeChannelLogo(context, channelId) + Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"") + + return channelId + } + + private fun upsertPreviewProgram( + context: Context, + channelId: Long, + item: JSONObject, + previousProgramId: Long, + weight: Int + ): Long { + val providerId = item.optString("id") + val imageUrl = item.optString("imageUrl") + + val builder = PreviewProgram.Builder() + .setChannelId(channelId) + .setType(programTypeFor(item.optString("itemType"))) + .setTitle(item.optString("title")) + .setInternalProviderId(providerId) + .setContentId(providerId) + .setIntentUri(buildIntentUri(context, item.optString("playRoute").ifBlank { item.optString("route") })) + .setWeight(weight) + + item.optString("subtitle").takeIf { it.isNotBlank() }?.let { + builder.setDescription(it) + } + + imageUrl.takeIf { it.isNotBlank() }?.let { + val imageUri = Uri.parse(it) + builder.setPosterArtUri(imageUri) + builder.setThumbnailUri(imageUri) + } + + + val contentValues = builder.build().toContentValues() + val contentResolver = context.contentResolver + + if (previousProgramId > 0L) { + val updatedRows = contentResolver.update( + TvContractCompat.buildPreviewProgramUri(previousProgramId), + contentValues, + null, + null + ) + + if (updatedRows > 0) { + Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId") + return previousProgramId + } + + Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row") + } + + val insertedUri = contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + contentValues + ) ?: return -1L + + val programId = ContentUris.parseId(insertedUri) + Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId") + return programId + } + + private fun buildIntentUri(context: Context, deepLink: String): Uri { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(deepLink) + `package` = context.packageName + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + return Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)) + } + + private fun programTypeFor(itemType: String): Int { + return when (itemType) { + "Movie" -> TvContractCompat.PreviewPrograms.TYPE_MOVIE + "Episode" -> TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE + "Series" -> TvContractCompat.PreviewPrograms.TYPE_TV_SERIES + else -> TvContractCompat.PreviewPrograms.TYPE_CLIP + } + } + + private fun storeChannelLogo(context: Context, channelId: Long) { + val bitmap = applicationIconBitmap(context) ?: return + val outputStream = context.contentResolver.openOutputStream( + TvContractCompat.buildChannelLogoUri(channelId) + ) ?: return + + outputStream.use { stream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.flush() + } + } + + private fun applicationIconBitmap(context: Context): Bitmap? { + val drawable = try { + context.packageManager.getApplicationIcon(context.packageName) + } catch (error: PackageManager.NameNotFoundException) { + Log.w(TAG, "Unable to load application icon", error) + return null + } + + return drawable.toBitmap() + } + + private fun Drawable.toBitmap(): Bitmap { + if (this is BitmapDrawable && bitmap != null) { + return bitmap + } + + val width = intrinsicWidth.takeIf { it > 0 } ?: 256 + val height = intrinsicHeight.takeIf { it > 0 } ?: 256 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap + } + + private fun preferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + private fun logProviderState(context: Context, channelId: Long) { + val contentResolver = context.contentResolver + + try { + contentResolver.query( + TvContractCompat.buildChannelUri(channelId), + null, + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE) + val packageNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_PACKAGE_NAME) + val displayNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) + + val browsable = if (browsableIndex >= 0) cursor.getInt(browsableIndex) else -1 + val packageName = if (packageNameIndex >= 0) cursor.getString(packageNameIndex) else "unknown" + val displayName = if (displayNameIndex >= 0) cursor.getString(displayNameIndex) else "unknown" + + Log.d( + TAG, + "logProviderState(): channelId=$channelId exists=true browsable=$browsable packageName=$packageName displayName=$displayName" + ) + } else { + Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") + } + } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") + } catch (error: Exception) { + Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error) + } + } +} diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt new file mode 100644 index 000000000..1fde77c7e --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt @@ -0,0 +1,18 @@ +package expo.modules.tvrecommendations + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.tvprovider.media.tv.TvContractCompat + +class TvRecommendationsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) { + return + } + + Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast") + TvRecommendationsPublisher.refreshFromCache(context) + } +} diff --git a/modules/tv-recommendations/expo-module.config.json b/modules/tv-recommendations/expo-module.config.json new file mode 100644 index 000000000..e017f17fd --- /dev/null +++ b/modules/tv-recommendations/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "tv-recommendations", + "version": "1.0.0", + "platforms": ["android"], + "android": { + "modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"] + } +} diff --git a/modules/tv-recommendations/index.ts b/modules/tv-recommendations/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/modules/tv-recommendations/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/modules/tv-recommendations/src/TvRecommendations.types.ts b/modules/tv-recommendations/src/TvRecommendations.types.ts new file mode 100644 index 000000000..1b21e5edd --- /dev/null +++ b/modules/tv-recommendations/src/TvRecommendations.types.ts @@ -0,0 +1,5 @@ +export interface TvRecommendationsModuleType { + syncRecommendations(json: string): boolean; + clearRecommendations(): boolean; + refreshRecommendations(): boolean; +} diff --git a/modules/tv-recommendations/src/TvRecommendationsModule.ts b/modules/tv-recommendations/src/TvRecommendationsModule.ts new file mode 100644 index 000000000..625e34da8 --- /dev/null +++ b/modules/tv-recommendations/src/TvRecommendationsModule.ts @@ -0,0 +1,26 @@ +import { requireNativeModule } from "expo-modules-core"; +import { Platform } from "react-native"; +import type { TvRecommendationsModuleType } from "./TvRecommendations.types"; + +let TvRecommendationsModule: TvRecommendationsModuleType | null = null; + +if (Platform.OS === "android" && Platform.isTV) { + try { + TvRecommendationsModule = + requireNativeModule("TvRecommendations"); + } catch { + TvRecommendationsModule = null; + } +} + +export function syncTvRecommendations(json: string): boolean { + return TvRecommendationsModule?.syncRecommendations(json) ?? false; +} + +export function clearTvRecommendations(): boolean { + return TvRecommendationsModule?.clearRecommendations() ?? false; +} + +export function refreshTvRecommendations(): boolean { + return TvRecommendationsModule?.refreshRecommendations() ?? false; +} diff --git a/modules/tv-recommendations/src/index.ts b/modules/tv-recommendations/src/index.ts new file mode 100644 index 000000000..c1798ae06 --- /dev/null +++ b/modules/tv-recommendations/src/index.ts @@ -0,0 +1,6 @@ +export type { TvRecommendationsModuleType } from "./TvRecommendations.types"; +export { + clearTvRecommendations, + refreshTvRecommendations, + syncTvRecommendations, +} from "./TvRecommendationsModule"; diff --git a/plugins/withTVOSTopShelf.js b/plugins/withTVOSTopShelf.js new file mode 100644 index 000000000..b37051348 --- /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 ebdaaca19..bd4d0f504 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -39,6 +39,7 @@ import { updateAccountToken, } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; +import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync"; interface Server { address: string; @@ -232,6 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const setServerMutation = useMutation({ mutationFn: async (server: Server) => { + clearTVDiscoverySafely(); 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 () => { + clearTVDiscoverySafely(); storage.remove("serverUrl"); setApi(null); }, @@ -361,6 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ); storage.remove("token"); + clearTVDiscoverySafely(); setUser(null); setApi(null); setPluginSettings(undefined); @@ -531,6 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ (newUrl: string) => { if (!jellyfin || !api?.accessToken) return; + clearTVDiscoverySafely(); 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 000000000..184b8d3d4 --- /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 000000000..d29ed85fd --- /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 000000000..ee73685d8 --- /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 000000000..78f220c8e --- /dev/null +++ b/utils/topshelf/cache.ts @@ -0,0 +1,57 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Platform } from "react-native"; +import { clearTopShelfCache, writeTopShelfCache } from "@/modules"; +import { + buildTVDiscoveryPayload, + type TVDiscoveryPayload, +} from "@/utils/tvDiscovery/payload"; + +export function updateTopShelfCache({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + const payload = buildTVDiscoveryPayload({ api, sections }); + if (!payload) { + clearTopShelfCacheSafely(); + return; + } + + writeTopShelfPayload(payload, api?.accessToken || undefined); +} + +export function writeTopShelfPayload( + payload: TVDiscoveryPayload, + apiKey?: string, +): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + try { + const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey); + + if (!didWrite) { + console.warn("[TopShelf] Native cache writer is unavailable"); + } + } catch (error) { + console.warn("[TopShelf] Failed to write cache", error); + } +} + +export function clearTopShelfCacheSafely(): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; + + try { + const didClear = clearTopShelfCache(); + + if (!didClear) { + console.warn("[TopShelf] Native cache clearer is unavailable"); + } + } catch (error) { + console.warn("[TopShelf] Failed to clear cache", error); + } +} diff --git a/utils/tvDiscovery/payload.ts b/utils/tvDiscovery/payload.ts new file mode 100644 index 000000000..6552c1988 --- /dev/null +++ b/utils/tvDiscovery/payload.ts @@ -0,0 +1,140 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; + +const TV_DISCOVERY_ITEM_LIMIT = 12; +const TV_DISCOVERY_SECTION_LIMIT = 3; + +export interface TVDiscoveryItem { + id: string; + itemType?: string; + title: string; + subtitle?: string; + imageUrl?: string; + route: string; + playRoute?: string; +} + +export interface TVDiscoverySection { + title: string; + items: TVDiscoveryItem[]; +} + +export interface TVDiscoveryPayload { + version: 1; + updatedAt: string; + sections: TVDiscoverySection[]; +} + +function getTVDiscoveryImageUrl( + 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 getTVDiscoveryTitle(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 getTVDiscoverySubtitle(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, +): TVDiscoverySection | null { + const payloadItems = (items || []) + .filter((item) => item.Id && item.Name) + .slice(0, TV_DISCOVERY_ITEM_LIMIT) + .map((item) => ({ + id: item.Id!, + itemType: item.Type || undefined, + title: getTVDiscoveryTitle(item), + subtitle: getTVDiscoverySubtitle(item), + imageUrl: getTVDiscoveryImageUrl(item, api), + route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, + playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, + })); + + if (payloadItems.length === 0) return null; + + return { + title, + items: payloadItems, + }; +} + +export function buildTVDiscoveryPayload({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): TVDiscoveryPayload | null { + if (!api) return null; + + const payloadSections = sections + .map((section) => sectionFromItems(section.title, section.items, api)) + .filter((section): section is TVDiscoverySection => section !== null) + .slice(0, TV_DISCOVERY_SECTION_LIMIT); + + if (payloadSections.length === 0) return null; + + return { + version: 1, + updatedAt: new Date().toISOString(), + sections: payloadSections, + }; +} diff --git a/utils/tvDiscovery/sync.ts b/utils/tvDiscovery/sync.ts new file mode 100644 index 000000000..4b92602fc --- /dev/null +++ b/utils/tvDiscovery/sync.ts @@ -0,0 +1,88 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Platform } from "react-native"; +import { clearTvRecommendations, syncTvRecommendations } from "@/modules"; +import { + clearTopShelfCacheSafely, + writeTopShelfPayload, +} from "@/utils/topshelf/cache"; +import { buildTVDiscoveryPayload } from "./payload"; + +export function updateTVDiscovery({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): void { + if (!Platform.isTV) return; + + const payload = buildTVDiscoveryPayload({ api, sections }); + + if (!payload) { + console.log("[TVDiscovery] No payload generated; clearing TV discovery"); + clearTVDiscoverySafely(); + return; + } + + const sectionSummary = payload.sections + .map((section) => `${section.title}:${section.items.length}`) + .join(", "); + console.log( + `[TVDiscovery] Sync payload prepared for ${Platform.OS} TV with ${payload.sections.length} section(s): ${sectionSummary}`, + ); + + if (Platform.OS === "ios") { + writeTopShelfPayload(payload, api?.accessToken || undefined); + return; + } + + if (Platform.OS === "android") { + try { + const didSync = syncTvRecommendations(JSON.stringify(payload)); + + console.log(`[TVDiscovery] Android sync result: ${didSync}`); + + if (!didSync) { + console.warn( + "[TVDiscovery] Android recommendations sync is unavailable", + ); + } + } catch (error) { + console.warn( + "[TVDiscovery] Failed to sync Android recommendations", + error, + ); + } + } +} + +export function clearTVDiscoverySafely(): void { + if (!Platform.isTV) return; + + console.log(`[TVDiscovery] Clearing TV discovery for ${Platform.OS} TV`); + + if (Platform.OS === "ios") { + clearTopShelfCacheSafely(); + return; + } + + if (Platform.OS === "android") { + try { + const didClear = clearTvRecommendations(); + + console.log(`[TVDiscovery] Android clear result: ${didClear}`); + + if (!didClear) { + console.warn( + "[TVDiscovery] Android recommendations clearer is unavailable", + ); + } + } catch (error) { + console.warn( + "[TVDiscovery] Failed to clear Android recommendations", + error, + ); + } + } +}