890 lines
28 KiB
TypeScript
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,
|
|
})
|
|
}
|