Files
jellybloom/src/hooks/use-jellyfin.ts
T

890 lines
28 KiB
TypeScript

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<string, number>()
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<string, { id: string; name: string; type: string; played: boolean }>()
// 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<string, { id: string; name: string; type: string; played: boolean }>()
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,
})
}