From df17c7ab9552f18b551c0d186023afbe54651572 Mon Sep 17 00:00:00 2001 From: lashman Date: Wed, 25 Mar 2026 19:11:34 +0200 Subject: [PATCH] hooks for jellyfin data, playback, tmdb, player chrome --- src/hooks/jellyfin/use-media-segments.ts | 130 ++++ src/hooks/use-arr.ts | 104 +++ src/hooks/use-availability.ts | 142 ++++ src/hooks/use-canon-list.ts | 32 + src/hooks/use-collection-meter.ts | 59 ++ src/hooks/use-external.ts | 206 ++++++ src/hooks/use-fuzzy-search.ts | 60 ++ src/hooks/use-hover-trailer.ts | 67 ++ src/hooks/use-jellyfin.ts | 886 +++++++++++++++++++++++ src/hooks/use-new-releases.ts | 66 ++ src/hooks/use-past-sentinel.ts | 59 ++ src/hooks/use-person-spotlights.ts | 124 ++++ src/hooks/use-playback-report.ts | 87 +++ src/hooks/use-playback-reporting.ts | 138 ++++ src/hooks/use-player-audio-graph.ts | 37 + src/hooks/use-player-chrome.ts | 54 ++ src/hooks/use-player-navigation.ts | 50 ++ src/hooks/use-player-panels.ts | 47 ++ src/hooks/use-player-picture-filter.ts | 31 + src/hooks/use-player-shortcuts.ts | 53 ++ src/hooks/use-prebuffer.ts | 81 +++ src/hooks/use-reduced-motion.ts | 31 + src/hooks/use-syncplay.ts | 98 +++ src/hooks/use-tmdb-detail.ts | 61 ++ src/hooks/use-tmdb.ts | 216 ++++++ src/hooks/use-trakt-scrobble.ts | 78 ++ src/hooks/use-watchlist.ts | 69 ++ 27 files changed, 3066 insertions(+) create mode 100644 src/hooks/jellyfin/use-media-segments.ts create mode 100644 src/hooks/use-arr.ts create mode 100644 src/hooks/use-availability.ts create mode 100644 src/hooks/use-canon-list.ts create mode 100644 src/hooks/use-collection-meter.ts create mode 100644 src/hooks/use-external.ts create mode 100644 src/hooks/use-fuzzy-search.ts create mode 100644 src/hooks/use-hover-trailer.ts create mode 100644 src/hooks/use-jellyfin.ts create mode 100644 src/hooks/use-new-releases.ts create mode 100644 src/hooks/use-past-sentinel.ts create mode 100644 src/hooks/use-person-spotlights.ts create mode 100644 src/hooks/use-playback-report.ts create mode 100644 src/hooks/use-playback-reporting.ts create mode 100644 src/hooks/use-player-audio-graph.ts create mode 100644 src/hooks/use-player-chrome.ts create mode 100644 src/hooks/use-player-navigation.ts create mode 100644 src/hooks/use-player-panels.ts create mode 100644 src/hooks/use-player-picture-filter.ts create mode 100644 src/hooks/use-player-shortcuts.ts create mode 100644 src/hooks/use-prebuffer.ts create mode 100644 src/hooks/use-reduced-motion.ts create mode 100644 src/hooks/use-syncplay.ts create mode 100644 src/hooks/use-tmdb-detail.ts create mode 100644 src/hooks/use-tmdb.ts create mode 100644 src/hooks/use-trakt-scrobble.ts create mode 100644 src/hooks/use-watchlist.ts diff --git a/src/hooks/jellyfin/use-media-segments.ts b/src/hooks/jellyfin/use-media-segments.ts new file mode 100644 index 0000000..7a205a9 --- /dev/null +++ b/src/hooks/jellyfin/use-media-segments.ts @@ -0,0 +1,130 @@ +import { useQuery } from '@tanstack/react-query' +import { getMediaSegmentsApi, jellyfinClient } from '../../api/jellyfin' +import { debugLog } from '../../lib/log' + +export type SegmentLike = { + Type: 'Intro' | 'Outro' | 'Recap' | 'Preview' | 'Commercial' | 'Unknown' + StartTicks: number + EndTicks: number +} + +const TICKS_PER_SECOND = 10_000_000 + +async function tryJsonFetch(url: string, token: string): Promise { + try { + const res = await fetch(url, { + headers: { 'X-MediaBrowser-Token': token }, + }) + if (!res.ok) { + debugLog('[segments] GET', url, '->', res.status) + return null + } + const text = await res.text() + debugLog('[segments] GET', url, '->', res.status, 'body:', text) + if (!text) return null + try { + return JSON.parse(text) + } catch { + return null + } + } catch (err) { + debugLog('[segments] GET', url, 'threw', err) + return null + } +} + +function pluginPayloadToSegments(data: any): SegmentLike[] { + if (!data) return [] + const out: SegmentLike[] = [] + if (data.Valid && Number.isFinite(data.IntroStart) && Number.isFinite(data.IntroEnd) && data.IntroEnd > data.IntroStart) { + out.push({ + Type: 'Intro', + StartTicks: Math.floor(Number(data.IntroStart) * TICKS_PER_SECOND), + EndTicks: Math.floor(Number(data.IntroEnd) * TICKS_PER_SECOND), + }) + } + if (data.Intro && Number.isFinite(data.Intro.Start) && Number.isFinite(data.Intro.End) && data.Intro.End > data.Intro.Start) { + out.push({ + Type: 'Intro', + StartTicks: Math.floor(Number(data.Intro.Start) * TICKS_PER_SECOND), + EndTicks: Math.floor(Number(data.Intro.End) * TICKS_PER_SECOND), + }) + } + if (data.Credits && Number.isFinite(data.Credits.Start) && Number.isFinite(data.Credits.End) && data.Credits.End > data.Credits.Start) { + out.push({ + Type: 'Outro', + StartTicks: Math.floor(Number(data.Credits.Start) * TICKS_PER_SECOND), + EndTicks: Math.floor(Number(data.Credits.End) * TICKS_PER_SECOND), + }) + } + if (Array.isArray(data.Items)) { + for (const it of data.Items) addSegment(out, it) + } + if (Array.isArray(data)) { + for (const it of data) addSegment(out, it) + } + return out +} + +function addSegment(out: SegmentLike[], it: any) { + if (!it?.Type) return + const startTicks = Number(it.StartTicks ?? 0) + const endTicks = Number(it.EndTicks ?? 0) + if (endTicks <= startTicks) return + out.push({ + Type: it.Type as SegmentLike['Type'], + StartTicks: startTicks, + EndTicks: endTicks, + }) +} + +export function useMediaSegments(itemId?: string) { + const api = jellyfinClient.getApi() + return useQuery({ + queryKey: ['jellyfin', 'media-segments', itemId], + queryFn: async () => { + if (!api || !itemId) return [] + const auth = jellyfinClient.getAuthState() + if (!auth) return [] + const { serverUrl, token } = auth + + const urls = [ + `${serverUrl}/MediaSegments/${itemId}`, + `${serverUrl}/Episode/${itemId}/IntroSkipperSegments`, + `${serverUrl}/Episode/${itemId}/Timestamps`, + `${serverUrl}/Episode/${itemId}/IntroTimestamps/v1`, + `${serverUrl}/Episode/${itemId}/IntroTimestamps`, + ] + + for (const url of urls) { + const data = await tryJsonFetch(url, token) + if (!data) continue + const segments = pluginPayloadToSegments(data) + if (segments.length > 0) { + debugLog('[segments] resolved from', url, segments) + return segments + } + } + + try { + const res = await getMediaSegmentsApi(api).getItemSegments({ itemId }) + const items = (res.data.Items || []).map(s => ({ + Type: (s.Type || 'Unknown') as SegmentLike['Type'], + StartTicks: Number(s.StartTicks ?? 0), + EndTicks: Number(s.EndTicks ?? 0), + })) + if (items.length > 0) { + debugLog('[segments] resolved via SDK', items) + return items + } + } catch (err) { + debugLog('[segments] SDK call failed', err) + } + + debugLog('[segments] no data from any source for', itemId) + return [] + }, + enabled: !!api && !!itemId, + staleTime: 60 * 60 * 1000, + }) +} diff --git a/src/hooks/use-arr.ts b/src/hooks/use-arr.ts new file mode 100644 index 0000000..c07fc6d --- /dev/null +++ b/src/hooks/use-arr.ts @@ -0,0 +1,104 @@ +import { useQuery } from '@tanstack/react-query' +import { useArrInstances, type ArrInstance } from '../stores/arr-instances-store' +import { radarrClient } from '../api/radarr' +import { sonarrClient } from '../api/sonarr' + +const STALE = 5 * 60 * 1000 + +/** + * React Query bindings for the Sonarr / Radarr clients. Hooks return + * `null` when no instance is configured for the requested kind+tier so + * callers can render seerr-style "configure to enable" affordances. + */ + +export function useArrSystemStatus(instance: ArrInstance | null | undefined) { + return useQuery({ + queryKey: ['arr', 'status', instance?.id], + queryFn: () => + instance + ? instance.kind === 'radarr' + ? radarrClient(instance).systemStatus() + : sonarrClient(instance).systemStatus() + : Promise.resolve(null), + enabled: !!instance, + staleTime: STALE, + retry: 0, + }) +} + +export function useRadarrLibrary(tier: 'default' | '4k' = 'default') { + const instance = useArrInstances(s => s.pick('radarr', tier)) + return useQuery({ + queryKey: ['radarr', 'library', instance?.id], + queryFn: () => (instance ? radarrClient(instance).movies() : Promise.resolve(null)), + enabled: !!instance, + staleTime: STALE, + }) +} + +export function useSonarrLibrary(tier: 'default' | '4k' = 'default') { + const instance = useArrInstances(s => s.pick('sonarr', tier)) + return useQuery({ + queryKey: ['sonarr', 'library', instance?.id], + queryFn: () => (instance ? sonarrClient(instance).series() : Promise.resolve(null)), + enabled: !!instance, + staleTime: STALE, + }) +} + +export function useRadarrQueue(tier: 'default' | '4k' = 'default') { + const instance = useArrInstances(s => s.pick('radarr', tier)) + return useQuery({ + queryKey: ['radarr', 'queue', instance?.id], + queryFn: () => (instance ? radarrClient(instance).queue() : Promise.resolve(null)), + enabled: !!instance, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }) +} + +export function useSonarrQueue(tier: 'default' | '4k' = 'default') { + const instance = useArrInstances(s => s.pick('sonarr', tier)) + return useQuery({ + queryKey: ['sonarr', 'queue', instance?.id], + queryFn: () => (instance ? sonarrClient(instance).queue() : Promise.resolve(null)), + enabled: !!instance, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }) +} + +export function useArrProfiles(instance: ArrInstance | null | undefined) { + return useQuery({ + queryKey: ['arr', 'profiles', instance?.id], + queryFn: () => { + if (!instance) return Promise.resolve(null) + if (instance.kind === 'radarr') return radarrClient(instance).qualityProfiles() + return sonarrClient(instance).qualityProfiles() + }, + enabled: !!instance, + staleTime: 60 * 60 * 1000, + }) +} + +export function useArrRootFolders(instance: ArrInstance | null | undefined) { + return useQuery({ + queryKey: ['arr', 'rootfolders', instance?.id], + queryFn: () => { + if (!instance) return Promise.resolve(null) + if (instance.kind === 'radarr') return radarrClient(instance).rootFolders() + return sonarrClient(instance).rootFolders() + }, + enabled: !!instance, + staleTime: 60 * 60 * 1000, + }) +} + +export function useSonarrLanguageProfiles(instance: ArrInstance | null | undefined) { + return useQuery({ + queryKey: ['sonarr', 'language-profiles', instance?.id], + queryFn: () => (instance && instance.kind === 'sonarr' ? sonarrClient(instance).languageProfiles() : Promise.resolve(null)), + enabled: !!instance && instance.kind === 'sonarr', + staleTime: 60 * 60 * 1000, + }) +} diff --git a/src/hooks/use-availability.ts b/src/hooks/use-availability.ts new file mode 100644 index 0000000..96b3fdf --- /dev/null +++ b/src/hooks/use-availability.ts @@ -0,0 +1,142 @@ +import { useMemo } from 'react' +import { useLibraryByTmdbId } from './use-jellyfin' +import { useRadarrLibrary, useSonarrLibrary, useRadarrQueue, useSonarrQueue } from './use-arr' + +/** + * Unified per-item availability state. Combines what Jellyfin already + * has with what Sonarr/Radarr know about so the UI can label every + * recommendation with one of: + * - 'available' - playable in Jellyfin right now + * - 'partial' - in Sonarr with some episodes downloaded but not all + * - 'processing' - being downloaded by *arr at this moment + * - 'pending' - in *arr, monitored, but no file yet (waiting on + * indexer / release) + * - 'requested' - in *arr, monitored, but inactive + * - 'missing' - not in any system + */ + +export type Availability = 'available' | 'partial' | 'processing' | 'pending' | 'requested' | 'missing' + +export interface AvailabilityRecord { + status: Availability + /** Filled when the item lives in Sonarr/Radarr - useful for cancel / + * search-now actions on the Requests page. */ + arrId?: number + arrKind?: 'sonarr' | 'radarr' + arrTier?: 'default' | '4k' + /** Local Jellyfin id when matched. */ + jellyfinId?: string +} + +/** + * Compute the availability map keyed by TMDB id. Reads all four + * regular+4K *arr libraries plus their queues. The map is stable as + * long as the upstream queries don't change so consumers can pass it + * through `useMemo` cheaply. + */ +export function useAvailability() { + const lib = useLibraryByTmdbId() + const radarrA = useRadarrLibrary('default') + const radarrB = useRadarrLibrary('4k') + const sonarrA = useSonarrLibrary('default') + const sonarrB = useSonarrLibrary('4k') + const radarrQA = useRadarrQueue('default') + const radarrQB = useRadarrQueue('4k') + const sonarrQA = useSonarrQueue('default') + const sonarrQB = useSonarrQueue('4k') + + const map = useMemo(() => { + const out = new Map() + + // Jellyfin first - "available" beats everything *arr might say. + if (lib.data) { + for (const [tmdbId, info] of lib.data) { + out.set(tmdbId, { status: 'available', jellyfinId: info.id }) + } + } + + function applyMovieList( + list: { id?: number; tmdbId: number; hasFile?: boolean; monitored?: boolean }[] | null | undefined, + tier: 'default' | '4k', + queue: { records?: { movieId?: number }[] } | null | undefined, + ) { + if (!list) return + const queueIds = new Set((queue?.records || []).map(r => r.movieId).filter((x): x is number => x != null)) + for (const m of list) { + if (!m.tmdbId) continue + const key = String(m.tmdbId) + const existing = out.get(key) + if (existing && existing.status === 'available') continue + let status: Availability + if (m.hasFile) status = 'available' + else if (m.id != null && queueIds.has(m.id)) status = 'processing' + else if (m.monitored) status = 'pending' + else status = 'requested' + out.set(key, { status, arrId: m.id, arrKind: 'radarr', arrTier: tier }) + } + } + + function applySeriesList( + list: { id?: number; tmdbId?: number; tvdbId?: number; imdbId?: string | null; seasons?: { monitored: boolean; statistics?: { episodeFileCount: number; episodeCount: number } }[] }[] | null | undefined, + tier: 'default' | '4k', + queue: { records?: { seriesId?: number }[] } | null | undefined, + ) { + if (!list) return + const queueIds = new Set((queue?.records || []).map(r => r.seriesId).filter((x): x is number => x != null)) + for (const s of list) { + const tmdb = s.tmdbId ? String(s.tmdbId) : null + if (!tmdb) continue + const existing = out.get(tmdb) + if (existing && existing.status === 'available') continue + let status: Availability + const seasons = s.seasons || [] + const totalEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeCount || 0), 0) + const haveEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeFileCount || 0), 0) + if (s.id != null && queueIds.has(s.id)) { + status = 'processing' + } else if (totalEps > 0 && haveEps >= totalEps) { + status = 'available' + } else if (haveEps > 0) { + status = 'partial' + } else if (seasons.some(x => x.monitored)) { + status = 'pending' + } else { + status = 'requested' + } + out.set(tmdb, { status, arrId: s.id, arrKind: 'sonarr', arrTier: tier }) + } + } + + applyMovieList(radarrA.data, 'default', radarrQA.data) + applyMovieList(radarrB.data, '4k', radarrQB.data) + applySeriesList(sonarrA.data, 'default', sonarrQA.data) + applySeriesList(sonarrB.data, '4k', sonarrQB.data) + + return out + }, [ + lib.data, + radarrA.data, + radarrB.data, + sonarrA.data, + sonarrB.data, + radarrQA.data, + radarrQB.data, + sonarrQA.data, + sonarrQB.data, + ]) + + return { + map, + isLoading: lib.isLoading || radarrA.isLoading || radarrB.isLoading || sonarrA.isLoading || sonarrB.isLoading, + } +} + +/** + * Single-item availability lookup. Convenience over reading from the + * full map directly when a component only cares about one tmdb id. + */ +export function useItemAvailability(tmdbId: string | number | null | undefined): AvailabilityRecord | null { + const { map } = useAvailability() + if (tmdbId == null) return null + return map.get(String(tmdbId)) || null +} diff --git a/src/hooks/use-canon-list.ts b/src/hooks/use-canon-list.ts new file mode 100644 index 0000000..e8300f2 --- /dev/null +++ b/src/hooks/use-canon-list.ts @@ -0,0 +1,32 @@ +import { useQueries } from '@tanstack/react-query' +import { getMovieFull } from '../api/tmdb' + +const STALE = 7 * 24 * 60 * 60 * 1000 + +/** + * Resolve a list of TMDB movie ids into their TMDB-shaped objects in + * the original order, dropping entries TMDB doesn't recognise. Used by + * the home page's "Discover canon" rows so the bundled lists in + * `canon-lists.ts` can render through the same TMDB->BaseItemDto + * mapping path as everything else. + */ +export function useCanonListResolved(ids: number[]) { + const queries = useQueries({ + queries: ids.map(id => ({ + queryKey: ['tmdb', 'movie-full', id], + queryFn: () => getMovieFull(id), + staleTime: STALE, + })), + }) + const results: any[] = [] + for (let i = 0; i < ids.length; i++) { + const data = queries[i]?.data + if (data) { + results.push({ ...data, media_type: 'movie' }) + } + } + return { + items: results, + isLoading: queries.some(q => q.isLoading), + } +} diff --git a/src/hooks/use-collection-meter.ts b/src/hooks/use-collection-meter.ts new file mode 100644 index 0000000..3f715be --- /dev/null +++ b/src/hooks/use-collection-meter.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query' +import { getCollection, getMovieFull } from '../api/tmdb' +import { useLibraryByTmdbId } from './use-jellyfin' +import type { BaseItemDto } from '../api/types' +import { getTmdbId } from '../lib/item-types' + +/** + * For a movie, lazy-fetch its TMDB collection (if any) and compute the + * user's progress through it. Returns null when the movie isn't part of + * a collection or until both fetches resolve. + * + * Gated by an `armed` flag (typically the same hover-delay used by the + * trailer hook) so we don't burn N+1 TMDB requests on first paint of a + * library grid. + */ +export function useCollectionMeter( + item: BaseItemDto | null | undefined, + armed: boolean, +) { + const tmdbId = getTmdbId(item) + const numericTmdb = tmdbId ? Number(tmdbId) : null + const isMovie = item?.Type === 'Movie' + + const movieQ = useQuery({ + queryKey: ['tmdb', 'movie-full', numericTmdb], + queryFn: () => (numericTmdb ? getMovieFull(numericTmdb) : null), + enabled: !!numericTmdb && isMovie && armed, + staleTime: 24 * 60 * 60 * 1000, + }) + const collectionId = movieQ.data?.belongs_to_collection?.id ?? null + + const colQ = useQuery({ + queryKey: ['tmdb', 'collection', collectionId], + queryFn: () => (collectionId ? getCollection(collectionId) : null), + enabled: !!collectionId && armed, + staleTime: 7 * 24 * 60 * 60 * 1000, + }) + + const libQ = useLibraryByTmdbId() + + if (!collectionId || !colQ.data || !libQ.data) return null + const parts = colQ.data.parts || [] + if (parts.length < 2) return null + let inLibrary = 0 + let watched = 0 + for (const p of parts) { + const local = libQ.data.get(String(p.id)) + if (local) { + inLibrary++ + if (local.played) watched++ + } + } + return { + collectionName: movieQ.data?.belongs_to_collection?.name || colQ.data.name, + total: parts.length, + inLibrary, + watched, + } +} diff --git a/src/hooks/use-external.ts b/src/hooks/use-external.ts new file mode 100644 index 0000000..b4cf53d --- /dev/null +++ b/src/hooks/use-external.ts @@ -0,0 +1,206 @@ +import { useQuery } from '@tanstack/react-query' +import { usePreferencesStore } from '../stores/preferences-store' +import { + tvmazeLookupByImdbId, + tvmazeLookupByTvdbId, + tvmazeShow, +} from '../api/tvmaze' +import { wikipediaResolve, wikipediaSection, wikipediaSummary } from '../api/wikipedia' +import { cinemetaMovie, cinemetaSeries } from '../api/cinemeta' +import { fanartMovie, fanartTv } from '../api/fanart' +import { getAwards, getAwardWinners, getFilmingLocations } from '../api/wikidata' +import { getMovieRating as rtMovie, getTvRating as rtTv } from '../api/rotten-tomatoes' + +/** + * React Query hooks for all the keyless / single-key external sources we + * pull. Each is independent and silently no-ops when the input id is missing + * or the source returns nothing - callers can chain them without guarding. + */ + +const DAY = 24 * 60 * 60 * 1000 + +/* ── TVmaze ─────────────────────────────────────────────────── */ + +export function useTvmazeByImdbId(imdbId?: string | null) { + return useQuery({ + queryKey: ['tvmaze', 'imdb', imdbId], + queryFn: () => (imdbId ? tvmazeLookupByImdbId(imdbId) : null), + enabled: !!imdbId, + staleTime: DAY, + }) +} + +export function useTvmazeByTvdbId(tvdbId?: string | number | null) { + return useQuery({ + queryKey: ['tvmaze', 'tvdb', tvdbId], + queryFn: () => (tvdbId ? tvmazeLookupByTvdbId(tvdbId) : null), + enabled: !!tvdbId, + staleTime: DAY, + }) +} + +export function useTvmazeShow(id?: number | null) { + return useQuery({ + queryKey: ['tvmaze', 'show', id], + queryFn: () => (id ? tvmazeShow(id) : null), + enabled: !!id, + staleTime: DAY, + }) +} + +/* ── Wikipedia ──────────────────────────────────────────────── */ + +export function useWikiSummary(title?: string | null) { + return useQuery({ + queryKey: ['wiki', 'summary', title], + queryFn: () => (title ? wikipediaSummary(title) : null), + enabled: !!title, + staleTime: 7 * DAY, + }) +} + +/** + * Resolves a free-text query (movie/show/person name) to a Wikipedia + * article and returns its lead summary. Used when we don't already have + * a known article title. + */ +export function useWikiResolve(query?: string | null, enabled = true) { + return useQuery({ + queryKey: ['wiki', 'resolve', query], + queryFn: () => (query ? wikipediaResolve(query) : null), + enabled: !!query && enabled, + staleTime: 7 * DAY, + }) +} + +/** + * Pull a specific top-level section's plain text from a Wikipedia article. + * Used by the "Production trivia" block on detail pages, which surfaces + * the article's "Production" section when present. + */ +export function useWikiSection(title?: string | null, sectionName?: string | null) { + return useQuery({ + queryKey: ['wiki', 'section', title, sectionName], + queryFn: () => (title && sectionName ? wikipediaSection(title, sectionName) : null), + enabled: !!title && !!sectionName, + staleTime: 7 * DAY, + }) +} + +/* ── Wikidata (SPARQL) ──────────────────────────────────────── */ + +export function useWikidataAwards(qid?: string | null) { + return useQuery({ + queryKey: ['wikidata', 'awards', qid], + queryFn: () => (qid ? getAwards(qid) : []), + enabled: !!qid, + staleTime: 7 * DAY, + }) +} + +export function useWikidataLocations(qid?: string | null) { + return useQuery({ + queryKey: ['wikidata', 'locations', qid], + queryFn: () => (qid ? getFilmingLocations(qid) : []), + enabled: !!qid, + staleTime: 7 * DAY, + }) +} + +/** + * All winners of the given award (Wikidata Q-id) with TMDB cross-refs + * when available. Used by the "Award winners you're missing" home row. + */ +export function useWikidataAwardWinners(awardQid?: string | null) { + return useQuery({ + queryKey: ['wikidata', 'award-winners', awardQid], + queryFn: () => (awardQid ? getAwardWinners(awardQid) : []), + enabled: !!awardQid, + staleTime: 30 * DAY, + }) +} + +/* ── Cinemeta (IMDB-style fallback) ─────────────────────────── */ + +export function useCinemeta(imdbId?: string | null, type?: 'movie' | 'series') { + return useQuery({ + queryKey: ['cinemeta', type, imdbId], + queryFn: () => { + if (!imdbId || !type) return null + return type === 'movie' ? cinemetaMovie(imdbId) : cinemetaSeries(imdbId) + }, + enabled: !!imdbId && !!type, + staleTime: DAY, + }) +} + +/* ── Key-gated services (TMDB, Fanart.tv) ────────────────────── */ + +const BUILT_IN_TMDB_KEY_X = (import.meta.env.VITE_TMDB_API_KEY || '').trim() +const BUILT_IN_FANART_KEY = (import.meta.env.VITE_FANART_API_KEY || '').trim() + +function effectiveFanartKey(userKey: string): string { + return userKey || BUILT_IN_FANART_KEY +} + +/** + * Reactive: returns true when ANY TMDB key is available (user override + * in prefs, mirror entry in localStorage, or build-time default). The + * UI uses this to hide TMDB-dependent rows entirely so the user never + * sees an empty "Trending today" header with no content underneath. + */ +export function useHasTmdbKey(): boolean { + const userKey = usePreferencesStore(s => s.tmdbApiKey) + if (userKey) return true + try { + if (localStorage.getItem('jf_tmdb_key')) return true + } catch { /* ignore */ } + return !!BUILT_IN_TMDB_KEY_X +} + +export function useHasFanartKey(): boolean { + const userKey = usePreferencesStore(s => s.fanartApiKey) + return !!userKey || !!BUILT_IN_FANART_KEY +} + +export function useFanartMovie(idTmdbOrImdb?: string | null) { + const userKey = usePreferencesStore(s => s.fanartApiKey) + const apiKey = effectiveFanartKey(userKey) + return useQuery({ + queryKey: ['fanart', 'movie', idTmdbOrImdb, apiKey], + queryFn: () => (idTmdbOrImdb && apiKey ? fanartMovie(idTmdbOrImdb, apiKey) : null), + enabled: !!idTmdbOrImdb && !!apiKey, + staleTime: 3 * DAY, + }) +} + +export function useFanartTv(tvdbId?: string | null) { + const userKey = usePreferencesStore(s => s.fanartApiKey) + const apiKey = effectiveFanartKey(userKey) + return useQuery({ + queryKey: ['fanart', 'tv', tvdbId, apiKey], + queryFn: () => (tvdbId && apiKey ? fanartTv(tvdbId, apiKey) : null), + enabled: !!tvdbId && !!apiKey, + staleTime: 3 * DAY, + }) +} + +/* ── Rotten Tomatoes (private Algolia, no key required) ───── */ + +export function useRottenTomatoes( + name?: string | null, + year?: number | null, + kind?: 'movie' | 'tv' | null, +) { + return useQuery({ + queryKey: ['rt', kind, name, year], + queryFn: () => + name && kind + ? kind === 'movie' + ? rtMovie(name, year || undefined) + : rtTv(name, year || undefined) + : null, + enabled: !!name && !!kind, + staleTime: 7 * DAY, + }) +} diff --git a/src/hooks/use-fuzzy-search.ts b/src/hooks/use-fuzzy-search.ts new file mode 100644 index 0000000..1a038aa --- /dev/null +++ b/src/hooks/use-fuzzy-search.ts @@ -0,0 +1,60 @@ +import { useEffect, useMemo, useState } from 'react' +import Fuse from 'fuse.js' +import { useFullLibraryCatalog, useEpisodeAndTrackSearch } from './use-jellyfin' +import type { BaseItemDto } from '../api/types' + +/** + * Real fuzzy search across the user's library. Pre-fetches the full + * Movies + Series + MusicAlbum + MusicArtist catalog once (cached 12h) + * and runs Fuse.js client-side so typos, partial words, and out-of- + * order tokens all resolve. Episodes and tracks fall back to Jellyfin's + * server-side searchTerm because pre-fetching every episode of every + * series would be enormous. + * + * The Fuse threshold is intentionally generous (0.4) so single-letter + * typos still surface the right title; we cap at 80 results so the UI + * stays manageable even for libraries with 5000+ items. + */ +export function useFuzzyLibrarySearch(query: string) { + const catalog = useFullLibraryCatalog() + const epAndTrack = useEpisodeAndTrackSearch(query) + + // Local debounce so Fuse doesn't re-run on every keystroke. The + // catalog is stable so this is the only thing changing the result. + const [debounced, setDebounced] = useState(query) + useEffect(() => { + const t = setTimeout(() => setDebounced(query), 120) + return () => clearTimeout(t) + }, [query]) + + const fuse = useMemo(() => { + const items = catalog.data || [] + if (items.length === 0) return null + return new Fuse(items as BaseItemDto[], { + keys: [ + { name: 'Name', weight: 0.7 }, + { name: 'OriginalTitle', weight: 0.25 }, + { name: 'Overview', weight: 0.05 }, + ], + threshold: 0.4, + ignoreLocation: true, + shouldSort: true, + includeScore: true, + minMatchCharLength: 2, + }) + }, [catalog.data]) + + const results = useMemo(() => { + const trimmed = debounced.trim() + if (!trimmed || !fuse) return [] + return fuse.search(trimmed, { limit: 80 }).map(r => r.item) + }, [fuse, debounced]) + + return { + catalogResults: results, + episodesAndTracks: (epAndTrack.data || []) as BaseItemDto[], + isLoading: catalog.isLoading || epAndTrack.isLoading, + isFetching: catalog.isFetching || epAndTrack.isFetching, + catalogReady: !!fuse, + } +} diff --git a/src/hooks/use-hover-trailer.ts b/src/hooks/use-hover-trailer.ts new file mode 100644 index 0000000..af409a1 --- /dev/null +++ b/src/hooks/use-hover-trailer.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { getMovieFull, getTvShowFull } from '../api/tmdb' +import type { BaseItemDto } from '../api/types' +import { getTmdbId } from '../lib/item-types' + +/** + * Returns the YouTube key of the first official Trailer/Teaser for the + * given item, lazy-fetched only after the user has hovered the card for + * `delayMs`. Avoids slamming TMDB when users sweep the cursor across a + * grid. + * + * The fetch is gated by an `armed` boolean toggled by the consumer + * (PosterCard's hover effect) so we don't dispatch network requests for + * every card on the screen, only the ones the user lingers on. + */ +export function useHoverTrailer( + item: BaseItemDto | null | undefined, + hovered: boolean, + enabled: boolean, + delayMs = 700, +): { videoKey: string | null; ready: boolean } { + const [armed, setArmed] = useState(false) + + // Arm only after the user has stayed on the card for delayMs. + useEffect(() => { + if (!hovered || !enabled) { + setArmed(false) + return + } + const id = setTimeout(() => setArmed(true), delayMs) + return () => clearTimeout(id) + }, [hovered, enabled, delayMs]) + + const tmdbId = getTmdbId(item) + const numericId = tmdbId ? Number(tmdbId) : null + const isTv = item?.Type === 'Series' + + const movieQ = useQuery({ + queryKey: ['tmdb', 'movie-full', numericId], + queryFn: () => (numericId ? getMovieFull(numericId) : null), + enabled: !!numericId && !isTv && armed, + staleTime: 24 * 60 * 60 * 1000, + }) + const tvQ = useQuery({ + queryKey: ['tmdb', 'tv-full', numericId], + queryFn: () => (numericId ? getTvShowFull(numericId) : null), + enabled: !!numericId && isTv && armed, + staleTime: 24 * 60 * 60 * 1000, + }) + + const videos = (isTv ? tvQ.data?.videos?.results : movieQ.data?.videos?.results) || [] + // Prefer official trailers, then teasers; restricted to YouTube since + // we render via the YT iframe player. + const pickPriority = (t: string) => (t === 'Trailer' ? 0 : t === 'Teaser' ? 1 : 2) + const trailer = [...videos] + .filter(v => v.site === 'YouTube' && v.key) + .sort((a, b) => { + if (a.official !== b.official) return a.official ? -1 : 1 + return pickPriority(a.type) - pickPriority(b.type) + })[0] + + return { + videoKey: trailer?.key || null, + ready: armed && (movieQ.isFetched || tvQ.isFetched), + } +} diff --git a/src/hooks/use-jellyfin.ts b/src/hooks/use-jellyfin.ts new file mode 100644 index 0000000..dd2ceb3 --- /dev/null +++ b/src/hooks/use-jellyfin.ts @@ -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() + 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, + }) +} diff --git a/src/hooks/use-new-releases.ts b/src/hooks/use-new-releases.ts new file mode 100644 index 0000000..14eedbe --- /dev/null +++ b/src/hooks/use-new-releases.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { jellyfinClient, getItemsApi } from '../api/jellyfin' +import { toast } from '../stores/toast-store' + +/** + * Background poller that checks for newly-added library items and + * surfaces toast notifications. Stores the most recent `DateCreated` + * timestamp in localStorage so restarts don't re-notify about old + * items. + */ +export function useNewReleaseNotifications(enabled: boolean) { + const qc = useQueryClient() + const lastNotifiedRef = useRef(() => { + try { return localStorage.getItem('jf_last_notify_date') } catch { return null } + }()) + + useEffect(() => { + if (!enabled) return + const api = jellyfinClient.getApi() + if (!api) return + + async function check() { + try { + const res = await getItemsApi(api).getItems({ + userId: jellyfinClient.getAuthState()!.userId, + sortBy: ['DateCreated'], + sortOrder: ['Descending'], + limit: 5, + recursive: true, + includeItemTypes: ['Movie', 'Series'], + fields: ['DateCreated'], + } as any) + const items = res.data.Items || [] + const newOnes = items.filter(it => { + const created = it.DateCreated + if (!created) return false + if (!lastNotifiedRef.current) return false + return created > lastNotifiedRef.current + }) + if (newOnes.length > 0) { + const first = newOnes[0] + toast( + `${newOnes.length} new ${newOnes.length === 1 ? 'item' : 'items'} added - ${first.Name}`, + 'success', + ) + } + const newest = items[0]?.DateCreated + if (newest) { + lastNotifiedRef.current = newest + try { localStorage.setItem('jf_last_notify_date', newest) } catch { /* noop */ } + } + // Refresh the home page recently-added cache + qc.invalidateQueries({ queryKey: ['jellyfin', 'home', 'recentlyAdded'] }) + } catch { /* ignore */ } + } + + // First check after a short delay so the app has finished loading + const first = setTimeout(check, 30_000) + const interval = setInterval(check, 5 * 60 * 1000) + return () => { + clearTimeout(first) + clearInterval(interval) + } + }, [enabled, qc]) +} diff --git a/src/hooks/use-past-sentinel.ts b/src/hooks/use-past-sentinel.ts new file mode 100644 index 0000000..03ffce9 --- /dev/null +++ b/src/hooks/use-past-sentinel.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * Detects whether a sentinel element has scrolled above the top of the + * nearest scroll container (the AppShell's `main.content-scroll`). + * Returns `true` when the user has moved past the sentinel, `false` + * while the sentinel is still in or below the viewport. + * + * Uses a ref-callback API rather than a `useRef` so the listener + * actually attaches when the sentinel mounts. With a plain useRef the + * setup effect runs once on first commit - which on pages that show a + * skeleton until data loads is BEFORE the real sentinel div renders, + * leaving `ref.current` null and the listener never wired up. + * + * Usage: + * const { sentinelRef, past } = usePastSentinel() + * return
+ */ +export function usePastSentinel(): { + sentinelRef: (el: HTMLElement | null) => void + past: boolean +} { + const [el, setEl] = useState(null) + const [past, setPast] = useState(false) + + // Wrap the state setter so React doesn't change the callback identity + // every render - the `ref` prop sees a stable function. + const sentinelRef = useCallback((next: HTMLElement | null) => { + setEl(next) + }, []) + + useEffect(() => { + if (!el) return + const root = (document.querySelector('main.content-scroll') as HTMLElement | null) || null + let raf = 0 + const update = () => { + raf = 0 + const rect = el.getBoundingClientRect() + const rootTop = root ? root.getBoundingClientRect().top : 0 + setPast(rect.top <= rootTop + 1) + } + const schedule = () => { + if (raf) return + raf = requestAnimationFrame(update) + } + schedule() + const target: EventTarget = root || window + target.addEventListener('scroll', schedule, { passive: true }) + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null + ro?.observe(el) + return () => { + if (raf) cancelAnimationFrame(raf) + target.removeEventListener('scroll', schedule) + ro?.disconnect() + } + }, [el]) + + return { sentinelRef, past } +} diff --git a/src/hooks/use-person-spotlights.ts b/src/hooks/use-person-spotlights.ts new file mode 100644 index 0000000..2d653c1 --- /dev/null +++ b/src/hooks/use-person-spotlights.ts @@ -0,0 +1,124 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { getMovieFull, getTvShowFull } from '../api/tmdb' +import type { BaseItemDto } from '../api/types' + +const STALE = 24 * 60 * 60 * 1000 + +export interface PersonSpotlight { + id: number + name: string + profile_path: string | null + /** Library item ids that contributed to this person's tally. */ + contributingItemIds: string[] + /** Aggregate count - cast/director appearances among watched items. */ + count: number +} + +/** + * Aggregates directors and lead actors across a user's recently + * watched items, returning the persons who appear at least twice. + * + * Implementation: batch-fetches TMDB full-credits for the top-N most + * recently watched items via React Query's `useQueries`. Both director + * (crew.job === 'Director') and top-billed cast (first 5) are tallied. + * + * Capped at `limit` items to keep the home page request burst small. + */ +export function usePersonSpotlights( + recent: BaseItemDto[] | null | undefined, + limit = 8, +) { + const seeds = useMemo(() => { + if (!recent) return [] as Array<{ tmdbId: number; type: 'movie' | 'tv'; itemId: string }> + const out: Array<{ tmdbId: number; type: 'movie' | 'tv'; itemId: string }> = [] + for (const it of recent) { + const t = it.ProviderIds?.Tmdb + if (!t || !it.Id) continue + const tmdbId = Number(t) + if (!Number.isFinite(tmdbId)) continue + const type: 'movie' | 'tv' = it.Type === 'Series' ? 'tv' : 'movie' + out.push({ tmdbId, type, itemId: it.Id }) + if (out.length >= limit) break + } + return out + }, [recent, limit]) + + const queries = useQueries({ + queries: seeds.map(s => ({ + queryKey: ['tmdb', s.type === 'tv' ? 'tv-full' : 'movie-full', s.tmdbId], + queryFn: () => + s.type === 'tv' ? getTvShowFull(s.tmdbId) : getMovieFull(s.tmdbId), + staleTime: STALE, + })), + }) + + return useMemo(() => { + const directorMap = new Map() + const actorMap = new Map() + + seeds.forEach((seed, i) => { + const data: any = queries[i]?.data + if (!data) return + const credits = data.credits + if (!credits) return + + const directors = (credits.crew || []).filter((c: any) => c.job === 'Director') + for (const d of directors) { + const cur = directorMap.get(d.id) + if (cur) { + cur.count += 1 + if (!cur.contributingItemIds.includes(seed.itemId)) { + cur.contributingItemIds.push(seed.itemId) + } + } else { + directorMap.set(d.id, { + id: d.id, + name: d.name, + profile_path: d.profile_path, + count: 1, + contributingItemIds: [seed.itemId], + }) + } + } + + // Top 5 billed cast members per item; deeper than that is mostly + // ensemble noise that distorts the recommendation. + const billed = (credits.cast || []).slice(0, 5) + for (const c of billed) { + const cur = actorMap.get(c.id) + if (cur) { + cur.count += 1 + if (!cur.contributingItemIds.includes(seed.itemId)) { + cur.contributingItemIds.push(seed.itemId) + } + } else { + actorMap.set(c.id, { + id: c.id, + name: c.name, + profile_path: c.profile_path, + count: 1, + contributingItemIds: [seed.itemId], + }) + } + } + }) + + const topDirector = [...directorMap.values()] + .filter(d => d.count >= 2) + .sort((a, b) => b.count - a.count)[0] || null + + const topActor = [...actorMap.values()] + .filter(a => a.count >= 2) + // Avoid actor === director collision so we don't render the same + // person twice on the home page. + .filter(a => a.id !== topDirector?.id) + .sort((a, b) => b.count - a.count)[0] || null + + return { + director: topDirector, + actor: topActor, + isLoading: queries.some(q => q.isLoading), + } + }, [seeds, queries]) +} diff --git a/src/hooks/use-playback-report.ts b/src/hooks/use-playback-report.ts new file mode 100644 index 0000000..75d6d46 --- /dev/null +++ b/src/hooks/use-playback-report.ts @@ -0,0 +1,87 @@ +import { jellyfinClient, getPlaystateApi } from '../api/jellyfin' + +/** + * Returns the API client + userId, or null if either is missing. We never + * throw synchronously from this path - the report* functions are fire-and- + * forget from useEffect, and a synchronous throw escapes the .catch() on + * the returned promise, surfacing in the React ErrorBoundary instead of + * being silently dropped. + */ +function tryGetApi() { + const api = jellyfinClient.getApi() + const userId = jellyfinClient.getAuthState()?.userId + if (!api || !userId) return null + return { api, userId } +} + +/** + * Common payload bits the server uses to track an active session. Mirrors + * the fields the official jellyfin-web client sends - in particular the + * `PlaySessionId` is what lets the server clean up an orphaned transcode + * when the user closes the window mid-playback, and `PlayMethod` is what + * the Active Devices dashboard groups on. + */ +export interface PlaybackContext { + itemId: string + mediaSourceId?: string + positionTicks?: number + playSessionId?: string + playMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode' + audioStreamIndex?: number + subtitleStreamIndex?: number + isPaused?: boolean + isMuted?: boolean + volumeLevel?: number +} + +export async function reportPlaybackStart(ctx: PlaybackContext) { + const conn = tryGetApi() + if (!conn) return + await getPlaystateApi(conn.api).reportPlaybackStart({ + playbackStartInfo: { + ItemId: ctx.itemId, + MediaSourceId: ctx.mediaSourceId, + PositionTicks: ctx.positionTicks, + PlaySessionId: ctx.playSessionId, + PlayMethod: ctx.playMethod, + AudioStreamIndex: ctx.audioStreamIndex, + SubtitleStreamIndex: ctx.subtitleStreamIndex, + VolumeLevel: ctx.volumeLevel, + IsMuted: ctx.isMuted, + CanSeek: true, + }, + }) +} + +export async function reportPlaybackProgress(ctx: PlaybackContext) { + const conn = tryGetApi() + if (!conn) return + await getPlaystateApi(conn.api).reportPlaybackProgress({ + playbackProgressInfo: { + ItemId: ctx.itemId, + MediaSourceId: ctx.mediaSourceId, + PositionTicks: ctx.positionTicks, + PlaySessionId: ctx.playSessionId, + PlayMethod: ctx.playMethod, + AudioStreamIndex: ctx.audioStreamIndex, + SubtitleStreamIndex: ctx.subtitleStreamIndex, + VolumeLevel: ctx.volumeLevel, + IsMuted: ctx.isMuted, + IsPaused: ctx.isPaused ?? false, + CanSeek: true, + }, + }) +} + +export async function reportPlaybackStop(ctx: PlaybackContext) { + const conn = tryGetApi() + if (!conn) return + await getPlaystateApi(conn.api).reportPlaybackStopped({ + playbackStopInfo: { + ItemId: ctx.itemId, + MediaSourceId: ctx.mediaSourceId, + PositionTicks: ctx.positionTicks, + PlaySessionId: ctx.playSessionId, + }, + }) +} diff --git a/src/hooks/use-playback-reporting.ts b/src/hooks/use-playback-reporting.ts new file mode 100644 index 0000000..b5583a9 --- /dev/null +++ b/src/hooks/use-playback-reporting.ts @@ -0,0 +1,138 @@ +import { useEffect, useRef, type MutableRefObject } from 'react' +import { + reportPlaybackStart, + reportPlaybackProgress, + reportPlaybackStop, +} from './use-playback-report' +import { debugLog } from '../lib/log' + +type PlayMethod = 'DirectPlay' | 'DirectStream' | 'Transcode' + +interface Args { + itemId: string | undefined + mediaSourceId: string | undefined + startTimeTicks: number | undefined + playSessionId: string | undefined + playMethod: PlayMethod | undefined + audioIndex: number | null + subtitleIndex: number | null + isPaused: boolean + isMuted: boolean + volume: number + /** + * Pinned ref to the latest playback position in ticks. The hook reads + * `.current` on every progress / stop tick so the caller only needs to + * keep it up-to-date - no deps churn. + */ + progressRef: MutableRefObject +} + +/** Defensive tick-to-number conversion - returns 0 for NaN / non-finite + * values so the report payload is always serialisable. */ +function ticksToNum(n: number): number { + if (!Number.isFinite(n)) return 0 + return Math.max(0, Math.floor(n)) +} + +/** + * Wires the Jellyfin Start / Progress / Stop reporting cycle for the + * current playback. Mirrors what the official jellyfin-web client sends: + * - PlaySessionId so the server can clean up an orphaned transcode if + * the user closes mid-playback + * - PlayMethod so the Active Devices dashboard shows the right mode + * - audio/subtitle index, pause, mute, and volume kept in sync + * + * Progress reports fire on a 5s interval - more granular than the + * previous 10s, not as chatty as jellyfin-web's per-timeupdate. + * + * The hook intentionally only re-runs on itemId. Session state changes + * (mute, pause, audio index) flow through a pinned ref so the interval + * picks them up without re-creating the reporting session. + */ +export function usePlaybackReporting(args: Args) { + const { + itemId, + mediaSourceId, + startTimeTicks, + playSessionId, + playMethod, + audioIndex, + subtitleIndex, + isPaused, + isMuted, + volume, + progressRef, + } = args + + const sessionRef = useRef<{ + playSessionId?: string + playMethod?: PlayMethod + audioIndex: number | null + subtitleIndex: number | null + isPaused: boolean + isMuted: boolean + volume: number + }>({ + playSessionId, + playMethod, + audioIndex, + subtitleIndex, + isPaused, + isMuted, + volume, + }) + // Pin the latest values without re-binding the interval. A deps change + // here would tear down + re-create the session, sending duplicate Start + // and Stop reports for the same play. + sessionRef.current = { + playSessionId, + playMethod, + audioIndex, + subtitleIndex, + isPaused, + isMuted, + volume, + } + + useEffect(() => { + if (!itemId) return + const safeStart = startTimeTicks && Number.isFinite(startTimeTicks) + ? Math.floor(startTimeTicks) + : undefined + + function buildContext(positionTicks: number) { + return { + itemId: itemId || '', + mediaSourceId, + positionTicks, + playSessionId: sessionRef.current.playSessionId, + playMethod: sessionRef.current.playMethod, + audioStreamIndex: sessionRef.current.audioIndex ?? undefined, + subtitleStreamIndex: sessionRef.current.subtitleIndex ?? undefined, + isPaused: sessionRef.current.isPaused, + isMuted: sessionRef.current.isMuted, + volumeLevel: Math.round((sessionRef.current.volume ?? 1) * 100), + } + } + + reportPlaybackStart({ ...buildContext(safeStart ?? 0), positionTicks: safeStart }).catch(err => + debugLog('[playback] start report failed', err), + ) + + const id = setInterval(() => { + reportPlaybackProgress(buildContext(ticksToNum(progressRef.current))).catch(err => + debugLog('[playback] progress report failed', err), + ) + }, 5000) + + const currentPosition = () => ticksToNum(progressRef.current) + + return () => { + clearInterval(id) + reportPlaybackStop(buildContext(currentPosition())).catch(err => + debugLog('[playback] stop report failed', err), + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [itemId]) +} diff --git a/src/hooks/use-player-audio-graph.ts b/src/hooks/use-player-audio-graph.ts new file mode 100644 index 0000000..1f8fec8 --- /dev/null +++ b/src/hooks/use-player-audio-graph.ts @@ -0,0 +1,37 @@ +import { useEffect, type RefObject } from 'react' +import type { MediaPlayerInstance } from '@vidstack/react' +import { usePreferencesStore } from '../stores/preferences-store' +import { usePlayerRuntimeStore } from '../stores/player-runtime-store' +import { applyAudioGraphState } from '../lib/audio-graph' + +/** + * Drives the Web Audio graph that sits between the `