feat(android-tv): TV recommendations (#1575)

This commit is contained in:
Steve Byatt
2026-05-21 12:55:26 +01:00
committed by GitHub
parent 121ff0eea0
commit 11a4f14732
18 changed files with 923 additions and 128 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

@@ -41,7 +41,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
import { updateTopShelfCache } from "@/utils/topshelf/cache";
import { updateTVDiscovery } from "@/utils/tvDiscovery/sync";
const HORIZONTAL_PADDING = scaleSize(60);
const TOP_PADDING = scaleSize(100);
@@ -260,7 +260,7 @@ export const Home = () => {
});
useEffect(() => {
updateTopShelfCache({
updateTVDiscovery({
api,
sections: [
{

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

@@ -31,3 +31,9 @@ export type {
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,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";

View File

@@ -39,7 +39,7 @@ import {
updateAccountToken,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
interface Server {
address: string;
@@ -233,7 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const setServerMutation = useMutation({
mutationFn: async (server: Server) => {
clearTopShelfCacheSafely();
clearTVDiscoverySafely();
const apiInstance = jellyfin?.createApi(server.address);
if (!apiInstance?.basePath) throw new Error("Failed to connect");
@@ -252,7 +252,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({
mutationFn: async () => {
clearTopShelfCacheSafely();
clearTVDiscoverySafely();
storage.remove("serverUrl");
setApi(null);
},
@@ -364,7 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
clearTopShelfCacheSafely();
clearTVDiscoverySafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
@@ -535,7 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
(newUrl: string) => {
if (!jellyfin || !api?.accessToken) return;
clearTopShelfCacheSafely();
clearTVDiscoverySafely();
const newApi = jellyfin.createApi(newUrl, api.accessToken);
setApi(newApi);
// Note: We don't update storage.set("serverUrl") here

View File

@@ -1,101 +1,11 @@
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 {
clearTopShelfCache,
type TopShelfCachePayload,
type TopShelfCacheSection,
writeTopShelfCache,
} from "@/modules";
const TOP_SHELF_ITEM_LIMIT = 12;
function getTopShelfImageUrl(item: BaseItemDto, api: Api): string | undefined {
const baseUrl = api.basePath;
if (item.Type === "Episode") {
if (item.SeriesId && item.SeriesPrimaryImageTag) {
return `${baseUrl}/Items/${item.SeriesId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.SeriesPrimaryImageTag)}&width=500`;
}
if (item.ParentPrimaryImageItemId && item.ParentPrimaryImageTag) {
return `${baseUrl}/Items/${item.ParentPrimaryImageItemId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.ParentPrimaryImageTag)}&width=500`;
}
}
const primaryTag = item.ImageTags?.Primary;
if (item.Id && primaryTag) {
return `${baseUrl}/Items/${item.Id}/Images/Primary?quality=90&tag=${encodeURIComponent(primaryTag)}&width=500`;
}
const backdropTag = item.BackdropImageTags?.[0];
if (item.Id && backdropTag) {
return `${baseUrl}/Items/${item.Id}/Images/Backdrop/0?quality=90&tag=${encodeURIComponent(backdropTag)}&width=800`;
}
return undefined;
}
function formatEpisodeNumber(item: BaseItemDto): string | undefined {
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season != null && episode != null) {
return `S${season} • E${episode}`;
}
if (season != null) return `Season ${season}`;
if (episode != null) return `Episode ${episode}`;
return undefined;
}
function getTopShelfTitle(item: BaseItemDto): string {
if (item.Type === "Episode") {
const episodeNumber = formatEpisodeNumber(item);
if (item.SeriesName && episodeNumber) {
return `${item.SeriesName} - ${episodeNumber}`;
}
if (item.SeriesName) return item.SeriesName;
if (episodeNumber) return episodeNumber;
return item.Name || "";
}
return item.Name || "";
}
function getTopShelfSubtitle(item: BaseItemDto): string | undefined {
if (item.Type === "Episode") return undefined;
return item.ProductionYear ? String(item.ProductionYear) : item.Type;
}
function sectionFromItems(
title: string,
items: BaseItemDto[] | undefined,
api: Api,
): TopShelfCacheSection | null {
const cacheItems = (items || [])
.filter((item) => item.Id && item.Name)
.slice(0, TOP_SHELF_ITEM_LIMIT)
.map((item) => ({
id: item.Id!,
title: getTopShelfTitle(item),
subtitle: getTopShelfSubtitle(item),
imageUrl: getTopShelfImageUrl(item, api),
route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`,
playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`,
}));
if (cacheItems.length === 0) return null;
return {
title,
items: cacheItems,
};
}
buildTVDiscoveryPayload,
type TVDiscoveryPayload,
} from "@/utils/tvDiscovery/payload";
export function updateTopShelfCache({
api,
@@ -106,40 +16,29 @@ export function updateTopShelfCache({
}): void {
if (Platform.OS !== "ios" || !Platform.isTV) return;
if (!api) {
const payload = buildTVDiscoveryPayload({ api, sections });
if (!payload) {
clearTopShelfCacheSafely();
return;
}
const payloadSections = sections
.map((section) => sectionFromItems(section.title, section.items, api))
.filter((section): section is TopShelfCacheSection => section !== null)
.slice(0, 3);
writeTopShelfPayload(payload, api?.accessToken || undefined);
}
if (payloadSections.length === 0) {
clearTopShelfCacheSafely();
return;
}
const payload: TopShelfCachePayload = {
version: 1,
updatedAt: new Date().toISOString(),
sections: payloadSections,
};
export function writeTopShelfPayload(
payload: TVDiscoveryPayload,
apiKey?: string,
): void {
if (Platform.OS !== "ios" || !Platform.isTV) return;
try {
const didWrite = writeTopShelfCache(
JSON.stringify(payload),
api.accessToken || undefined,
);
const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey);
if (__DEV__ && !didWrite) {
if (!didWrite) {
console.warn("[TopShelf] Native cache writer is unavailable");
}
} catch (error) {
if (__DEV__) {
console.warn("[TopShelf] Failed to write cache", error);
}
console.warn("[TopShelf] Failed to write cache", error);
}
}
@@ -149,12 +48,10 @@ export function clearTopShelfCacheSafely(): void {
try {
const didClear = clearTopShelfCache();
if (__DEV__ && !didClear) {
if (!didClear) {
console.warn("[TopShelf] Native cache clearer is unavailable");
}
} catch (error) {
if (__DEV__) {
console.warn("[TopShelf] Failed to clear cache", 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,
);
}
}
}