import { useEffect, useState } from 'react' import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query' import { jellyfinClient, getItemsApi, getTvShowsApi, getUserViewsApi, getSearchApi, getLibraryApi, getItemRefreshApi, getMediaInfoApi, getPlaylistsApi, getPlaystateApi, getUserLibraryApi } from '../api/jellyfin' import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api' import { debugLog } from '../lib/log' import { browserDeviceProfile } from '../lib/device-profile' import { usePreferencesStore } from '../stores/preferences-store' function useApi() { return jellyfinClient.getApi() } export function useHomeData() { const api = useApi() const continueWatching = useQuery({ queryKey: ['jellyfin', 'home', 'continueWatching'], queryFn: async () => { if (!api) throw new Error('Not authenticated') debugLog('[JF] Fetching continueWatching...') const res = await getItemsApi(api).getResumeItems({ userId: jellyfinClient.getAuthState()!.userId, limit: 12, fields: ['PrimaryImageAspectRatio', 'Overview', 'ChildCount', 'Genres'], enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb'], }) debugLog('[JF] continueWatching result:', res.data) return res.data.Items || [] }, enabled: !!api, }) const nextUp = useQuery({ queryKey: ['jellyfin', 'home', 'nextUp'], queryFn: async () => { if (!api) throw new Error('Not authenticated') debugLog('[JF] Fetching nextUp...') const res = await getTvShowsApi(api).getNextUp({ userId: jellyfinClient.getAuthState()!.userId, limit: 12, fields: ['PrimaryImageAspectRatio', 'Overview', 'ChildCount', 'Genres'], enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb'], }) debugLog('[JF] nextUp result:', res.data) return res.data.Items || [] }, enabled: !!api, }) const recentlyAdded = useQuery({ queryKey: ['jellyfin', 'home', 'recentlyAdded'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, sortBy: ['DateCreated'], sortOrder: ['Descending'], limit: 20, recursive: true, includeItemTypes: ['Movie', 'Series'], fields: ['PrimaryImageAspectRatio', 'Overview', 'Genres'], enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb'], } as any) return res.data.Items || [] }, enabled: !!api, }) return { continueWatching, nextUp, recentlyAdded } } export function useLibraries() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'libraries'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getUserViewsApi(api).getUserViews({ userId: jellyfinClient.getAuthState()!.userId, }) return res.data.Items || [] }, enabled: !!api, }) } export function useLibraryItems( parentId?: string, opts?: { sortBy?: string[] sortOrder?: string[] includeItemTypes?: string[] filters?: string[] genres?: string[] years?: number[] minCommunityRating?: number startIndex?: number limit?: number enabled?: boolean includePeople?: boolean }, ) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'library', parentId, opts], queryFn: async () => { if (!api) throw new Error('Not authenticated') debugLog('[JF] Fetching library items:', { parentId, opts, userId: jellyfinClient.getAuthState()!.userId }) const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, parentId, sortBy: opts?.sortBy || ['SortName'], sortOrder: opts?.sortOrder || ['Ascending'], includeItemTypes: opts?.includeItemTypes, filters: opts?.filters, genres: opts?.genres, years: opts?.years, minCommunityRating: opts?.minCommunityRating, startIndex: opts?.startIndex, limit: opts?.limit || 100, recursive: true, fields: [ 'PrimaryImageAspectRatio', 'Overview', 'ChildCount', 'RecursiveItemCount', 'MediaSources', 'MediaStreams', 'DateCreated', 'Genres', 'Studios', 'Tags', 'ProviderIds', 'Width', 'Height', 'RunTimeTicks', ...(opts?.includePeople ? (['People'] as const) : []), ], } as any) debugLog('[JF] Library items result:', res.data) return res.data }, enabled: !!api && (opts?.enabled ?? true), }) } /** * Bulk-fetch a set of Jellyfin items by their ids in one request. Used * by the Profile page's top-picks list which needs items keyed off * personal-rating ids (not necessarily played items). */ export function useItemsByIds(itemIds: string[]) { const api = useApi() const sortedIds = [...itemIds].sort() return useQuery({ queryKey: ['jellyfin', 'items-by-ids', sortedIds], queryFn: async () => { if (!api || itemIds.length === 0) return [] const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, ids: itemIds, fields: ['PrimaryImageAspectRatio', 'ProviderIds', 'Genres'], } as any) return res.data.Items || [] }, enabled: !!api && itemIds.length > 0, staleTime: 5 * 60 * 1000, }) } export function useItemDetails(itemId?: string) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'item', itemId], queryFn: async () => { if (!api || !itemId) throw new Error('Missing params') debugLog('[JF] Fetching item details:', itemId) const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, ids: [itemId], fields: [ 'PrimaryImageAspectRatio', 'Overview', 'ChildCount', 'ProviderIds', 'MediaSources', 'MediaStreams', 'Chapters', 'Trickplay', 'People', 'Studios', 'Tags', 'Taglines', 'Genres', 'RemoteTrailers', 'ExternalUrls', 'DateCreated', 'DateLastMediaAdded', 'Width', 'Height', 'Path', ], enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb', 'Banner', 'Disc', 'Art'], } as any) debugLog('[JF] Item details result:', res.data) return res.data.Items?.[0] || null }, enabled: !!api && !!itemId, }) } /** * Move a playlist entry to a new index. Optimistic - PlaylistView updates * its local copy immediately, and we just invalidate on settle so the next * read picks up the server-confirmed order. */ export function usePlaylistMove(playlistId?: string) { const api = useApi() const qc = useQueryClient() return useMutation({ mutationFn: async ({ playlistItemId, newIndex }: { playlistItemId: string; newIndex: number }) => { if (!api || !playlistId) throw new Error('Not ready') await getPlaylistsApi(api).moveItem({ playlistId, itemId: playlistItemId, newIndex }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['jellyfin', 'playlist-items', playlistId] }) }, }) } /** * Find or lazily create a user-private playlist with the given name. * Used by the watchlist feature to keep state on the server (so it * survives device changes) without burning a separate API. */ export function useNamedPlaylist(name: string) { const api = useApi() const qc = useQueryClient() return useQuery({ queryKey: ['jellyfin', 'named-playlist', name], queryFn: async () => { if (!api) return null const userId = jellyfinClient.getAuthState()!.userId // Look for an existing playlist owned by this user with the exact name. const list = await getItemsApi(api).getItems({ userId, recursive: true, includeItemTypes: ['Playlist'], fields: ['ChildCount'], } as any) const found = (list.data.Items || []).find( p => (p.Name || '').toLowerCase() === name.toLowerCase(), ) if (found?.Id) return found // Create a new empty playlist. MediaType "Mixed" is required by // some Jellyfin server versions; without it the server defaults // to a music playlist that silently rejects movie/series adds. const create = await getPlaylistsApi(api).createPlaylist({ createPlaylistDto: { Name: name, UserId: userId, Ids: [], MediaType: 'Mixed' as any }, } as any) const newId = create.data.Id if (!newId) return null qc.invalidateQueries({ queryKey: ['jellyfin', 'named-playlist', name] }) const fresh = await getItemsApi(api).getItems({ userId, ids: [newId], fields: ['ChildCount'], } as any) return fresh.data.Items?.[0] || null }, enabled: !!api, staleTime: 60 * 1000, }) } /** Add one or more item ids to a playlist. */ export function usePlaylistAdd(playlistId?: string) { const api = useApi() const qc = useQueryClient() return useMutation({ mutationFn: async (itemIds: string[]) => { if (!api || !playlistId || itemIds.length === 0) return await getPlaylistsApi(api).addItemToPlaylist({ playlistId, ids: itemIds, userId: jellyfinClient.getAuthState()!.userId, }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['jellyfin', 'playlist-items', playlistId] }) }, }) } /** Remove one or more entries from a playlist by their PlaylistItemId values. */ export function usePlaylistRemove(playlistId?: string) { const api = useApi() const qc = useQueryClient() return useMutation({ mutationFn: async (entryIds: string[]) => { if (!api || !playlistId || entryIds.length === 0) return await getPlaylistsApi(api).removeItemFromPlaylist({ playlistId, entryIds }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['jellyfin', 'playlist-items', playlistId] }) }, }) } /** * Bulk mark items as played or unplayed. Used by the playlist selection * toolbar - hits the playstate endpoint once per item. */ export function useBulkMarkPlayed() { const api = useApi() const qc = useQueryClient() return useMutation({ mutationFn: async ({ itemIds, played }: { itemIds: string[]; played: boolean }) => { if (!api || itemIds.length === 0) return const userId = jellyfinClient.getAuthState()!.userId await Promise.all( itemIds.map(id => played ? getPlaystateApi(api).markPlayedItem({ userId, itemId: id }) : getPlaystateApi(api).markUnplayedItem({ userId, itemId: id }), ), ) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['jellyfin'] }) }, }) } /** * Toggle favorite state for one or more items. Used both by the playlist * heart button (single item) and the selection toolbar (bulk). */ export function useBulkToggleFavorite() { const api = useApi() const qc = useQueryClient() return useMutation({ mutationFn: async ({ itemIds, favorite }: { itemIds: string[]; favorite: boolean }) => { if (!api || itemIds.length === 0) return const userId = jellyfinClient.getAuthState()!.userId await Promise.all( itemIds.map(id => favorite ? getUserLibraryApi(api).markFavoriteItem({ userId, itemId: id }) : getUserLibraryApi(api).unmarkFavoriteItem({ userId, itemId: id }), ), ) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['jellyfin'] }) }, }) } /** * Items inside a Playlist. Playlist contents come from a different endpoint * than regular folders (it preserves user-set order; regular `getItems` with * a parentId would re-order). */ export function usePlaylistItems(playlistId?: string) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'playlist-items', playlistId], queryFn: async () => { if (!api || !playlistId) return [] const res = await getPlaylistsApi(api).getPlaylistItems({ playlistId, userId: jellyfinClient.getAuthState()!.userId, fields: [ 'PrimaryImageAspectRatio', 'Overview', 'MediaSources', 'MediaStreams', 'Genres', 'ProviderIds', 'Width', 'Height', ], enableImageTypes: ['Primary', 'Backdrop', 'Thumb'], } as any) return res.data.Items || [] }, enabled: !!api && !!playlistId, }) } export function useSeasons(seriesId?: string) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'seasons', seriesId], queryFn: async () => { if (!api || !seriesId) throw new Error('Missing params') debugLog('[JF] Fetching seasons:', seriesId) const res = await getTvShowsApi(api).getSeasons({ seriesId, userId: jellyfinClient.getAuthState()!.userId, fields: ['PrimaryImageAspectRatio', 'Overview', 'ChildCount'], }) debugLog('[JF] Seasons result:', res.data) return res.data.Items || [] }, enabled: !!api && !!seriesId, }) } export function useEpisodes(seriesId?: string, seasonId?: string) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'episodes', seriesId, seasonId], queryFn: async () => { if (!api || !seriesId) throw new Error('Missing params') debugLog('[JF] Fetching episodes:', { seriesId, seasonId }) const res = await getTvShowsApi(api).getEpisodes({ seriesId, userId: jellyfinClient.getAuthState()!.userId, seasonId, fields: [ 'PrimaryImageAspectRatio', 'Overview', 'MediaSources', 'MediaStreams', 'Chapters', 'ProviderIds', 'Width', 'Height', ], enableImageTypes: ['Primary', 'Thumb'], } as any) debugLog('[JF] Episodes result:', res.data) return res.data.Items || [] }, enabled: !!api && !!seriesId, }) } /** * Fetch episodes for several seasons in parallel. Used by per-season UI * (sparklines, recap detection) that needs progress data without waiting * for the user to click each season. */ export function useSeasonsEpisodes(seriesId: string | undefined, seasonIds: string[]) { const api = useApi() const queries = useQueries({ queries: seasonIds.map(seasonId => ({ queryKey: ['jellyfin', 'episodes', seriesId, seasonId], queryFn: async () => { if (!api || !seriesId) throw new Error('Missing params') const res = await getTvShowsApi(api).getEpisodes({ seriesId, userId: jellyfinClient.getAuthState()!.userId, seasonId, fields: ['PrimaryImageAspectRatio', 'Overview', 'ProviderIds'], enableImageTypes: ['Primary', 'Thumb'], } as any) return res.data.Items || [] }, enabled: !!api && !!seriesId, })), }) return queries } export function useSearch(query: string) { const api = useApi() // Debounce the query so we don't fire a request on every keystroke. 250ms // is the sweet spot - feels responsive while collapsing rapid typing. const [debouncedQuery, setDebouncedQuery] = useState(query) useEffect(() => { const t = setTimeout(() => setDebouncedQuery(query), 250) return () => clearTimeout(t) }, [query]) return useQuery({ queryKey: ['jellyfin', 'search', debouncedQuery], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getSearchApi(api).getSearchHints({ userId: jellyfinClient.getAuthState()!.userId, searchTerm: debouncedQuery, includeItemTypes: ['Movie', 'Series', 'Episode', 'Audio', 'MusicAlbum', 'MusicArtist'], limit: 20, }) return res.data.SearchHints || [] }, enabled: !!api && debouncedQuery.length > 0, }) } /** * Pull the entire searchable library catalog once and cache it for 12h. * Returns Movies + Series + MusicAlbum + MusicArtist with full ImageTags * and ProviderIds so Fuse.js can rerank and PosterCard can render * covers. Capped at 5000 items per type to keep the payload bounded on * very large libraries. */ export function useFullLibraryCatalog() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'full-catalog'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const userId = jellyfinClient.getAuthState()!.userId const fields = [ 'ProviderIds', 'Overview', 'PrimaryImageAspectRatio', 'CommunityRating', 'OriginalTitle', 'PremiereDate', ] as any const res = await getItemsApi(api).getItems({ userId, recursive: true, includeItemTypes: ['Movie', 'Series', 'MusicAlbum', 'MusicArtist'] as any, sortBy: ['SortName'] as any, fields, limit: 5000, } as any) return res.data.Items || [] }, enabled: !!api, staleTime: 12 * 60 * 60 * 1000, gcTime: 24 * 60 * 60 * 1000, }) } /** * Server-side keyword search for high-cardinality types (Episodes, * Audio tracks) where pre-fetching everything is impractical. Returns * full BaseItemDto objects (not lightweight SearchHints) so we get * ImageTags + ProviderIds straight away. */ export function useEpisodeAndTrackSearch(query: string) { const api = useApi() const [debounced, setDebounced] = useState(query) useEffect(() => { const t = setTimeout(() => setDebounced(query), 220) return () => clearTimeout(t) }, [query]) return useQuery({ queryKey: ['jellyfin', 'kw-search', debounced], queryFn: async () => { if (!api) throw new Error('Not authenticated') const userId = jellyfinClient.getAuthState()!.userId const fields = [ 'ProviderIds', 'Overview', 'PrimaryImageAspectRatio', 'SeriesPrimaryImageTag', 'ParentBackdropImageTags', 'PremiereDate', 'CommunityRating', ] as any const res = await getItemsApi(api).getItems({ userId, recursive: true, searchTerm: debounced, includeItemTypes: ['Episode', 'Audio'] as any, fields, limit: 50, } as any) return res.data.Items || [] }, enabled: !!api && debounced.trim().length >= 2, }) } export function useNextUpForSeries(seriesId?: string) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'nextUp-series', seriesId], queryFn: async () => { if (!api || !seriesId) return null const res = await getTvShowsApi(api).getNextUp({ userId: jellyfinClient.getAuthState()!.userId, seriesId, limit: 1, fields: ['PrimaryImageAspectRatio', 'Overview', 'MediaSources', 'MediaStreams'], } as any) return res.data.Items?.[0] || null }, enabled: !!api && !!seriesId, }) } export function useSimilarItems(itemId?: string, limit = 12) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'similar', itemId, limit], queryFn: async () => { if (!api || !itemId) return [] try { const res = await getLibraryApi(api).getSimilarItems({ itemId, userId: jellyfinClient.getAuthState()!.userId, limit, fields: ['PrimaryImageAspectRatio', 'Overview', 'Genres', 'ProviderIds'], } as any) return res.data.Items || [] } catch { return [] } }, enabled: !!api && !!itemId, }) } /** * Establishes a server-side playback session for an item and returns the * resolved MediaSources with proper TranscodingUrl + PlaySessionId. The * server uses our DeviceProfile to decide direct-play vs HLS transcoding. * Without this call, the bare /main.m3u8 endpoint generates a playlist with * runtimeTicks=0, which makes segment fetches fail with 400. */ export function usePlaybackInfo( itemId?: string, startTimeTicks?: number, audioStreamIndex?: number, maxStreamingBitrate?: number, ) { const api = useApi() const audioPassthrough = usePreferencesStore(s => s.audioPassthrough) return useQuery({ queryKey: [ 'jellyfin', 'playback-info', itemId, startTimeTicks, audioStreamIndex, maxStreamingBitrate, ], queryFn: async () => { if (!api || !itemId) return null const res = await getMediaInfoApi(api).getPostedPlaybackInfo({ itemId, playbackInfoDto: { UserId: jellyfinClient.getAuthState()!.userId, // 0 = "no cap, prefer Original" - server will pick direct-stream // when source matches profile. Otherwise honour the user pick. MaxStreamingBitrate: maxStreamingBitrate || 140_000_000, StartTimeTicks: startTimeTicks, // Server-side audio track selection. When undefined the server // picks the default; when set, the returned TranscodingUrl muxes // that audio track into the stream. AudioStreamIndex: audioStreamIndex, DeviceProfile: browserDeviceProfile(audioPassthrough) as any, AutoOpenLiveStream: true, EnableDirectPlay: true, EnableDirectStream: true, EnableTranscoding: true, AllowVideoStreamCopy: true, AllowAudioStreamCopy: true, }, } as any) return res.data }, enabled: !!api && !!itemId, staleTime: 0, // Override the global 30-minute gcTime: each PlaybackInfoResponse // includes full MediaSources with encoding params, audio/video // streams, container-level metadata, etc. Bingeing through 20 // episodes would otherwise keep ~20 of these objects in memory // for half an hour. 60s is plenty for "user clicked play, the // stream URL came back" and avoids the buildup. gcTime: 60 * 1000, }) } export { useMediaSegments } from './jellyfin/use-media-segments' /** * Returns one representative episode for a series so we can show tech specs * (resolution / codec / audio) on a series detail page, since series-level * items have no MediaSources of their own. */ export function useSampleEpisode(seriesId?: string, enabled = true) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'sample-episode', seriesId], queryFn: async () => { if (!api || !seriesId) return null const res = await getTvShowsApi(api).getEpisodes({ seriesId, userId: jellyfinClient.getAuthState()!.userId, limit: 1, startIndex: 0, fields: ['MediaSources', 'MediaStreams', 'Width', 'Height', 'Path'], } as any) return res.data.Items?.[0] || null }, enabled: !!api && !!seriesId && enabled, staleTime: 5 * 60 * 1000, }) } /** * One-time fetch of every movie + series in the user's library, indexed by TMDB id. * Used to highlight which TMDB recommendations the user already owns. */ /** * Lightweight genre tally across the user's Movie + Series library. * Used by the Discover "library gap finder" to spot underrepresented * categories. Limited to 1500 items - enough to be representative on * even big libraries without paying for the whole catalog. */ export function useLibraryGenreDistribution() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'lib-genre-distribution'], queryFn: async () => { if (!api) return null const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, recursive: true, includeItemTypes: ['Movie', 'Series'], fields: ['Genres'], limit: 1500, } as any) const counts = new Map() const items = res.data.Items || [] for (const item of items) { for (const g of item.Genres || []) { counts.set(g, (counts.get(g) || 0) + 1) } } return { counts, total: items.length } }, enabled: !!api, staleTime: 30 * 60 * 1000, }) } export function useLibraryByTmdbId() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'library-by-tmdb-id'], queryFn: async () => { if (!api) return new Map() // Cap at 3000 to bound the size of this single big request - the // map drives in-library indicators across the whole app, so the // round-trip cost matters. Libraries beyond this size will see // some out-of-library cards mis-marked as missing; acceptable // until we wire pagination. const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, recursive: true, includeItemTypes: ['Movie', 'Series'], fields: ['ProviderIds'], limit: 3000, } as any) const map = new Map() for (const item of res.data.Items || []) { const tmdb = item.ProviderIds?.Tmdb if (tmdb && item.Id) { map.set(String(tmdb), { id: item.Id, name: item.Name || '', type: item.Type || 'Movie', played: !!item.UserData?.Played, }) } } return map }, enabled: !!api, staleTime: 5 * 60 * 1000, }) } /** * Triggers a metadata + image refresh on a specific Jellyfin item. */ export function useRefreshItem() { const api = useApi() const qc = useQueryClient() return useMutation({ mutationFn: async (params: { itemId: string replaceAllMetadata?: boolean replaceAllImages?: boolean }) => { if (!api) throw new Error('Not authenticated') const { itemId, replaceAllMetadata = false, replaceAllImages = false } = params await getItemRefreshApi(api).refreshItem({ itemId, metadataRefreshMode: 'FullRefresh' as any, imageRefreshMode: 'FullRefresh' as any, replaceAllMetadata, replaceAllImages, } as any) // Wait a beat then invalidate so the UI sees fresh data await new Promise(r => setTimeout(r, 1500)) }, onSuccess: (_data, vars) => { qc.invalidateQueries({ queryKey: ['jellyfin', 'item', vars.itemId] }) qc.invalidateQueries({ queryKey: ['jellyfin', 'episodes'] }) qc.invalidateQueries({ queryKey: ['jellyfin', 'seasons'] }) }, }) } export function useLiveTvInfo() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'live-tv', 'info'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getLiveTvApi(api).getLiveTvInfo() return res.data }, enabled: !!api, }) } export function useLiveTvChannels() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'live-tv', 'channels'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getLiveTvApi(api).getLiveTvChannels({ userId: jellyfinClient.getAuthState()!.userId, addCurrentProgram: true, enableImages: true, enableUserData: true, limit: 500, }) return res.data.Items || [] }, enabled: !!api, }) } export function useLiveTvRecordings() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'live-tv', 'recordings'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getLiveTvApi(api).getRecordings({ userId: jellyfinClient.getAuthState()!.userId, enableImages: true, enableUserData: true, limit: 200, }) return res.data.Items || [] }, enabled: !!api, }) } export function useLiveTvTimers() { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'live-tv', 'timers'], queryFn: async () => { if (!api) throw new Error('Not authenticated') const res = await getLiveTvApi(api).getTimers({ isScheduled: true }) return res.data.Items || [] }, enabled: !!api, }) } export function usePersonItems(personName?: string) { const api = useApi() return useQuery({ queryKey: ['jellyfin', 'person-items', personName], queryFn: async () => { if (!api || !personName) return [] const res = await getItemsApi(api).getItems({ userId: jellyfinClient.getAuthState()!.userId, person: personName, sortBy: ['PremiereDate'], sortOrder: ['Descending'], recursive: true, includeItemTypes: ['Movie', 'Series'], limit: 60, fields: ['PrimaryImageAspectRatio', 'Overview', 'Genres', 'ProviderIds'], } as any) return res.data.Items || [] }, enabled: !!api && !!personName, }) }