Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface

This commit is contained in:
Lance Chant
2026-05-21 13:56:04 +02:00
33 changed files with 1627 additions and 1 deletions

View File

@@ -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?

View File

@@ -140,6 +140,7 @@
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[
"./plugins/withGitPod.js",

33
app/topshelf/item.tsx Normal file
View File

@@ -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 <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

32
app/topshelf/play.tsx Normal file
View File

@@ -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 <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

View File

@@ -8,6 +8,8 @@
"!android",
"!Streamyfin.app",
"!utils/jellyseerr",
"!expo-env.d.ts",
"!modules/**/android/build",
"!.expo",
"!docs/jellyfin-openapi-stable.json"
]

View File

@@ -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],

136
docs/tv-discovery.md Normal file
View File

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

View File

@@ -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";

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["TopShelfCacheModule"]
}
}

View File

@@ -0,0 +1 @@
export * from "./src";

View File

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

View File

@@ -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
}
}

View File

@@ -0,0 +1,21 @@
export type TopShelfCacheModuleEvents = Record<string, never>;
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[];
}

View File

@@ -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<TopShelfCacheModuleEvents> {
writeCache(json: string, apiKey?: string): boolean;
clearCache(): boolean;
}
let TopShelfCacheNativeModule: TopShelfCacheModuleType | null = null;
if (Platform.OS === "ios" && Platform.isTV) {
try {
TopShelfCacheNativeModule =
requireNativeModule<TopShelfCacheModuleType>("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;
}

View File

@@ -0,0 +1,2 @@
export * from "./TopShelfCache.types";
export { clearTopShelfCache, writeTopShelfCache } from "./TopShelfCacheModule";

View File

@@ -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"
}
}

View File

@@ -0,0 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -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)
}
}
}

View File

@@ -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<String>()
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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "tv-recommendations",
"version": "1.0.0",
"platforms": ["android"],
"android": {
"modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"]
}
}

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,5 @@
export interface TvRecommendationsModuleType {
syncRecommendations(json: string): boolean;
clearRecommendations(): boolean;
refreshRecommendations(): boolean;
}

View File

@@ -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<TvRecommendationsModuleType>("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;
}

View File

@@ -0,0 +1,6 @@
export type { TvRecommendationsModuleType } from "./TvRecommendations.types";
export {
clearTvRecommendations,
refreshTvRecommendations,
syncTvRecommendations,
} from "./TvRecommendationsModule";

196
plugins/withTVOSTopShelf.js Normal file
View File

@@ -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;

View File

@@ -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]);

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>StreamyfinAppGroupIdentifier</key>
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>StreamyfinKeychainAccessGroupIdentifier</key>
<string>$(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>CFBundleDisplayName</key>
<string>Streamyfin Top Shelf</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.tv-top-shelf</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).TopShelfProvider</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>

View File

@@ -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<TVTopShelfSectionedItem>? 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)
}
}

57
utils/topshelf/cache.ts Normal file
View File

@@ -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);
}
}

View File

@@ -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,
};
}

88
utils/tvDiscovery/sync.ts Normal file
View File

@@ -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,
);
}
}
}