hooks for jellyfin data, playback, tmdb, player chrome
This commit is contained in:
@@ -0,0 +1,886 @@
|
||||
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'
|
||||
|
||||
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',
|
||||
...(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()
|
||||
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() 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user