mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-22 14:56:38 +01:00
feat(android-tv): TV recommendations (#1575)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
140
utils/tvDiscovery/payload.ts
Normal file
140
utils/tvDiscovery/payload.ts
Normal 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
88
utils/tvDiscovery/sync.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user