From 121ff0eea097b8a49f59bd7635b1fb8043560128 Mon Sep 17 00:00:00 2001
From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com>
Date: Thu, 21 May 2026 07:47:45 +0100
Subject: [PATCH 1/2] feat(tvos): Add TopShelf Extension (#1561)
---
app.json | 1 +
app/topshelf/item.tsx | 33 +++
app/topshelf/play.tsx | 32 +++
biome.json | 2 +
components/home/Home.tv.tsx | 13 ++
modules/index.ts | 7 +
.../top-shelf-cache/expo-module.config.json | 6 +
modules/top-shelf-cache/index.ts | 1 +
.../top-shelf-cache/ios/TopShelfCache.podspec | 23 ++
.../ios/TopShelfCacheModule.swift | 112 ++++++++++
.../src/TopShelfCache.types.ts | 21 ++
.../src/TopShelfCacheModule.ts | 37 ++++
modules/top-shelf-cache/src/index.ts | 2 +
plugins/withTVOSTopShelf.js | 196 ++++++++++++++++++
providers/JellyfinProvider.tsx | 8 +-
targets/StreamyfinTopShelf/Info.plist | 40 ++++
.../StreamyfinTopShelf.entitlements | 14 ++
.../StreamyfinTopShelf/TopShelfProvider.swift | 125 +++++++++++
utils/topshelf/cache.ts | 160 ++++++++++++++
19 files changed, 832 insertions(+), 1 deletion(-)
create mode 100644 app/topshelf/item.tsx
create mode 100644 app/topshelf/play.tsx
create mode 100644 modules/top-shelf-cache/expo-module.config.json
create mode 100644 modules/top-shelf-cache/index.ts
create mode 100644 modules/top-shelf-cache/ios/TopShelfCache.podspec
create mode 100644 modules/top-shelf-cache/ios/TopShelfCacheModule.swift
create mode 100644 modules/top-shelf-cache/src/TopShelfCache.types.ts
create mode 100644 modules/top-shelf-cache/src/TopShelfCacheModule.ts
create mode 100644 modules/top-shelf-cache/src/index.ts
create mode 100644 plugins/withTVOSTopShelf.js
create mode 100644 targets/StreamyfinTopShelf/Info.plist
create mode 100644 targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements
create mode 100644 targets/StreamyfinTopShelf/TopShelfProvider.swift
create mode 100644 utils/topshelf/cache.ts
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..482bdc616 100644
--- a/components/home/Home.tv.tsx
+++ b/components/home/Home.tv.tsx
@@ -41,6 +41,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
+import { updateTopShelfCache } from "@/utils/topshelf/cache";
const HORIZONTAL_PADDING = scaleSize(60);
const TOP_PADDING = scaleSize(100);
@@ -258,6 +259,18 @@ export const Home = () => {
refetchInterval: 60 * 1000,
});
+ useEffect(() => {
+ updateTopShelfCache({
+ api,
+ sections: [
+ {
+ title: t("home.continue_and_next_up"),
+ items: heroItems,
+ },
+ ],
+ });
+ }, [api, heroItems, t]);
+
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
diff --git a/modules/index.ts b/modules/index.ts
index d93e9077a..6e27ca007 100644
--- a/modules/index.ts
+++ b/modules/index.ts
@@ -24,3 +24,10 @@ export type {
VideoSource as MpvVideoSource,
} from "./mpv-player";
export { MpvPlayerView } from "./mpv-player";
+// Top Shelf cache (tvOS)
+export type {
+ TopShelfCacheItem,
+ TopShelfCachePayload,
+ TopShelfCacheSection,
+} from "./top-shelf-cache";
+export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache";
diff --git a/modules/top-shelf-cache/expo-module.config.json b/modules/top-shelf-cache/expo-module.config.json
new file mode 100644
index 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/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..2007de1c4 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -39,6 +39,7 @@ import {
updateAccountToken,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
+import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
interface Server {
address: string;
@@ -232,6 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const setServerMutation = useMutation({
mutationFn: async (server: Server) => {
+ clearTopShelfCacheSafely();
const apiInstance = jellyfin?.createApi(server.address);
if (!apiInstance?.basePath) throw new Error("Failed to connect");
@@ -250,6 +252,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({
mutationFn: async () => {
+ clearTopShelfCacheSafely();
storage.remove("serverUrl");
setApi(null);
},
@@ -361,6 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
+ clearTopShelfCacheSafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
@@ -531,6 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
(newUrl: string) => {
if (!jellyfin || !api?.accessToken) return;
+ clearTopShelfCacheSafely();
const newApi = jellyfin.createApi(newUrl, api.accessToken);
setApi(newApi);
// Note: We don't update storage.set("serverUrl") here
@@ -661,10 +666,11 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
if (loaded === false) return;
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
+ const isTopShelfLaunchRoute = segments[0] === "topshelf";
if (!user?.Id && inAuthGroup) {
router.replace("/login");
- } else if (user?.Id && !inAuthGroup) {
+ } else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) {
router.replace("/(auth)/(tabs)/(home)/");
}
}, [user, segments, loaded]);
diff --git a/targets/StreamyfinTopShelf/Info.plist b/targets/StreamyfinTopShelf/Info.plist
new file mode 100644
index 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..867797fd3
--- /dev/null
+++ b/utils/topshelf/cache.ts
@@ -0,0 +1,160 @@
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Platform } from "react-native";
+import {
+ clearTopShelfCache,
+ type TopShelfCachePayload,
+ type TopShelfCacheSection,
+ writeTopShelfCache,
+} from "@/modules";
+
+const TOP_SHELF_ITEM_LIMIT = 12;
+
+function getTopShelfImageUrl(item: BaseItemDto, api: Api): string | undefined {
+ const baseUrl = api.basePath;
+
+ if (item.Type === "Episode") {
+ if (item.SeriesId && item.SeriesPrimaryImageTag) {
+ return `${baseUrl}/Items/${item.SeriesId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.SeriesPrimaryImageTag)}&width=500`;
+ }
+
+ if (item.ParentPrimaryImageItemId && item.ParentPrimaryImageTag) {
+ return `${baseUrl}/Items/${item.ParentPrimaryImageItemId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.ParentPrimaryImageTag)}&width=500`;
+ }
+ }
+
+ const primaryTag = item.ImageTags?.Primary;
+ if (item.Id && primaryTag) {
+ return `${baseUrl}/Items/${item.Id}/Images/Primary?quality=90&tag=${encodeURIComponent(primaryTag)}&width=500`;
+ }
+
+ const backdropTag = item.BackdropImageTags?.[0];
+ if (item.Id && backdropTag) {
+ return `${baseUrl}/Items/${item.Id}/Images/Backdrop/0?quality=90&tag=${encodeURIComponent(backdropTag)}&width=800`;
+ }
+
+ return undefined;
+}
+
+function formatEpisodeNumber(item: BaseItemDto): string | undefined {
+ const season = item.ParentIndexNumber;
+ const episode = item.IndexNumber;
+
+ if (season != null && episode != null) {
+ return `S${season} • E${episode}`;
+ }
+
+ if (season != null) return `Season ${season}`;
+ if (episode != null) return `Episode ${episode}`;
+
+ return undefined;
+}
+
+function getTopShelfTitle(item: BaseItemDto): string {
+ if (item.Type === "Episode") {
+ const episodeNumber = formatEpisodeNumber(item);
+
+ if (item.SeriesName && episodeNumber) {
+ return `${item.SeriesName} - ${episodeNumber}`;
+ }
+
+ if (item.SeriesName) return item.SeriesName;
+ if (episodeNumber) return episodeNumber;
+ return item.Name || "";
+ }
+
+ return item.Name || "";
+}
+
+function getTopShelfSubtitle(item: BaseItemDto): string | undefined {
+ if (item.Type === "Episode") return undefined;
+
+ return item.ProductionYear ? String(item.ProductionYear) : item.Type;
+}
+
+function sectionFromItems(
+ title: string,
+ items: BaseItemDto[] | undefined,
+ api: Api,
+): TopShelfCacheSection | null {
+ const cacheItems = (items || [])
+ .filter((item) => item.Id && item.Name)
+ .slice(0, TOP_SHELF_ITEM_LIMIT)
+ .map((item) => ({
+ id: item.Id!,
+ title: getTopShelfTitle(item),
+ subtitle: getTopShelfSubtitle(item),
+ imageUrl: getTopShelfImageUrl(item, api),
+ route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`,
+ playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`,
+ }));
+
+ if (cacheItems.length === 0) return null;
+
+ return {
+ title,
+ items: cacheItems,
+ };
+}
+
+export function updateTopShelfCache({
+ api,
+ sections,
+}: {
+ api: Api | null | undefined;
+ sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
+}): void {
+ if (Platform.OS !== "ios" || !Platform.isTV) return;
+
+ if (!api) {
+ clearTopShelfCacheSafely();
+ return;
+ }
+
+ const payloadSections = sections
+ .map((section) => sectionFromItems(section.title, section.items, api))
+ .filter((section): section is TopShelfCacheSection => section !== null)
+ .slice(0, 3);
+
+ if (payloadSections.length === 0) {
+ clearTopShelfCacheSafely();
+ return;
+ }
+
+ const payload: TopShelfCachePayload = {
+ version: 1,
+ updatedAt: new Date().toISOString(),
+ sections: payloadSections,
+ };
+
+ try {
+ const didWrite = writeTopShelfCache(
+ JSON.stringify(payload),
+ api.accessToken || undefined,
+ );
+
+ if (__DEV__ && !didWrite) {
+ console.warn("[TopShelf] Native cache writer is unavailable");
+ }
+ } catch (error) {
+ if (__DEV__) {
+ console.warn("[TopShelf] Failed to write cache", error);
+ }
+ }
+}
+
+export function clearTopShelfCacheSafely(): void {
+ if (Platform.OS !== "ios" || !Platform.isTV) return;
+
+ try {
+ const didClear = clearTopShelfCache();
+
+ if (__DEV__ && !didClear) {
+ console.warn("[TopShelf] Native cache clearer is unavailable");
+ }
+ } catch (error) {
+ if (__DEV__) {
+ console.warn("[TopShelf] Failed to clear cache", error);
+ }
+ }
+}
From 11a4f147328aaf0f349108914755f3eb6578300e Mon Sep 17 00:00:00 2001
From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com>
Date: Thu, 21 May 2026 12:55:26 +0100
Subject: [PATCH 2/2] feat(android-tv): TV recommendations (#1575)
---
README.md | 4 +
components/home/Home.tv.tsx | 4 +-
docs/tv-discovery.md | 136 +++++++
modules/index.ts | 6 +
.../tv-recommendations/android/build.gradle | 46 +++
.../android/src/main/AndroidManifest.xml | 11 +
.../TvRecommendationsModule.kt | 25 ++
.../TvRecommendationsPublisher.kt | 378 ++++++++++++++++++
.../TvRecommendationsReceiver.kt | 18 +
.../expo-module.config.json | 8 +
modules/tv-recommendations/index.ts | 1 +
.../src/TvRecommendations.types.ts | 5 +
.../src/TvRecommendationsModule.ts | 26 ++
modules/tv-recommendations/src/index.ts | 6 +
providers/JellyfinProvider.tsx | 10 +-
utils/topshelf/cache.ts | 139 +------
utils/tvDiscovery/payload.ts | 140 +++++++
utils/tvDiscovery/sync.ts | 88 ++++
18 files changed, 923 insertions(+), 128 deletions(-)
create mode 100644 docs/tv-discovery.md
create mode 100644 modules/tv-recommendations/android/build.gradle
create mode 100644 modules/tv-recommendations/android/src/main/AndroidManifest.xml
create mode 100644 modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt
create mode 100644 modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt
create mode 100644 modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt
create mode 100644 modules/tv-recommendations/expo-module.config.json
create mode 100644 modules/tv-recommendations/index.ts
create mode 100644 modules/tv-recommendations/src/TvRecommendations.types.ts
create mode 100644 modules/tv-recommendations/src/TvRecommendationsModule.ts
create mode 100644 modules/tv-recommendations/src/index.ts
create mode 100644 utils/tvDiscovery/payload.ts
create mode 100644 utils/tvDiscovery/sync.ts
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/components/home/Home.tv.tsx b/components/home/Home.tv.tsx
index 482bdc616..cffff54fa 100644
--- a/components/home/Home.tv.tsx
+++ b/components/home/Home.tv.tsx
@@ -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: [
{
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 6e27ca007..1f2b458f0 100644
--- a/modules/index.ts
+++ b/modules/index.ts
@@ -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";
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/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 2007de1c4..bd4d0f504 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -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
diff --git a/utils/topshelf/cache.ts b/utils/topshelf/cache.ts
index 867797fd3..78f220c8e 100644
--- a/utils/topshelf/cache.ts
+++ b/utils/topshelf/cache.ts
@@ -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);
}
}
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,
+ );
+ }
+ }
+}