fix remaining build errors

This commit is contained in:
2026-05-01 08:30:36 +03:00
parent 8886abf589
commit 2c317fb0ec
24 changed files with 90 additions and 64 deletions
+16 -6
View File
@@ -461,12 +461,22 @@ export interface TmdbSearchMultiResult extends TmdbMovie {
known_for?: any[] known_for?: any[]
} }
/** TMDB discover result - union of movie and tv with optional media_type. */ /** TMDB discover result - permissive interface for movie / tv / multi. */
export type TmdbDiscoverItem = export interface TmdbDiscoverItem {
| TmdbMovie id: number
| TmdbTvShow title?: string
| (TmdbMovie & { media_type: string }) name?: string
| (TmdbTvShow & { media_type: string }) overview?: string
poster_path?: string | null
backdrop_path?: string | null
release_date?: string
first_air_date?: string
vote_average?: number
vote_count?: number
adult?: boolean
original_language?: string
media_type?: string
}
/* ────────────────────────────────────────────────────────────── */ /* ────────────────────────────────────────────────────────────── */
/* Fetcher */ /* Fetcher */
+1 -1
View File
@@ -277,7 +277,7 @@ export default function DetailHero({
<RequestButton <RequestButton
tmdbId={Number(tmdbId)} tmdbId={Number(tmdbId)}
kind={item.Type === 'Movie' ? 'movie' : 'tv'} kind={item.Type === 'Movie' ? 'movie' : 'tv'}
tmdbData={tmdbData} tmdbData={tmdbData || null}
/> />
)} )}
+4 -3
View File
@@ -10,6 +10,7 @@ import type {
TmdbKeyword, TmdbKeyword,
TmdbReview, TmdbReview,
TmdbVideo, TmdbVideo,
TmdbCastMember,
} from '../../api/tmdb' } from '../../api/tmdb'
import type { CinemetaMeta } from '../../api/cinemeta' import type { CinemetaMeta } from '../../api/cinemeta'
import type { TvmazeShow } from '../../api/tvmaze' import type { TvmazeShow } from '../../api/tvmaze'
@@ -66,8 +67,8 @@ interface Props {
wikiTitle: string | null wikiTitle: string | null
region: string region: string
watchProviders: TmdbWatchProviders | null | undefined watchProviders: TmdbWatchProviders | null | undefined
cast: unknown[] cast: TmdbCastMember[]
crew: unknown[] crew: TmdbCastMember[]
keywords: TmdbKeyword[] keywords: TmdbKeyword[]
reviews: TmdbReview[] reviews: TmdbReview[]
videos: TmdbVideo[] | undefined videos: TmdbVideo[] | undefined
@@ -76,7 +77,7 @@ interface Props {
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
overview: string overview: string
overviewSource: string | null overviewSource: string | null
sources: { Id?: string }[] sources: { Id?: string | null }[]
activeSourceId: string | null activeSourceId: string | null
onSourceChange: (id: string | null) => void onSourceChange: (id: string | null) => void
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export default function VersionsSelector({ item, selectedSourceId, onChange }: P
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{sources.map(src => { {sources.map(src => {
const summary = summarizeSource(src) const summary = summarizeSource(src)
const id = src.Id || src.id || '' const id = src.Id || ''
const active = id === selectedSourceId const active = id === selectedSourceId
return ( return (
<button <button
+1 -1
View File
@@ -140,7 +140,7 @@ function DecadeRow({ decade, kind }: { decade: Decade; kind: 'movie' | 'tv' }) {
const data = kind === 'movie' ? movieQuery.data : tvQuery.data const data = kind === 'movie' ? movieQuery.data : tvQuery.data
const items = useMemo(() => { const items = useMemo(() => {
const raw = (data?.results || []).map(m => ({ ...m, media_type: kind as const })) const raw = (data?.results || []).map(m => ({ ...m, media_type: kind }))
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!m.adult), lib.data) return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!m.adult), lib.data)
}, [data, lib.data, hideAdult, kind]) }, [data, lib.data, hideAdult, kind])
+1 -1
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Shuffle, RotateCw, ArrowRight, Star, X } from '../../lib/icons' import { Shuffle, RotateCw, ArrowRight, Star, X } from '../../lib/icons'
@@ -41,6 +41,7 @@ export function SpotlightHero({ kind = 'all' }: { kind?: 'all' | 'movie' | 'tv'
const mediaType: 'movie' | 'tv' = pick.media_type === 'tv' || pick.first_air_date ? 'tv' : 'movie' const mediaType: 'movie' | 'tv' = pick.media_type === 'tv' || pick.first_air_date ? 'tv' : 'movie'
function open() { function open() {
if (!pick) return
navigate(`/item/tmdb-${mediaType}-${pick.id}`) navigate(`/item/tmdb-${mediaType}-${pick.id}`)
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import { useLibraryByTmdbId, useLibraryGenreDistribution } from '../../hooks/use
import { usePreferencesStore } from '../../stores/preferences-store' import { usePreferencesStore } from '../../stores/preferences-store'
import { filterToMissing } from '../../pages/discover/helpers' import { filterToMissing } from '../../pages/discover/helpers'
import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres' import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres'
import type { TmdbDiscoverItem } from '../../api/tmdb'
const TMDB_IMG = 'https://image.tmdb.org/t/p' const TMDB_IMG = 'https://image.tmdb.org/t/p'
@@ -66,7 +67,7 @@ export default function TonightHero({ kind }: Props) {
const pick = useMemo(() => { const pick = useMemo(() => {
// Personalized pool wins when present + non-empty. // Personalized pool wins when present + non-empty.
const personalRaw = (personalData?.results || []).map(m => ({ ...m, media_type: kind as const })) const personalRaw = (personalData?.results || []).map(m => ({ ...m, media_type: kind }))
const personalPool = filterToMissing(personalRaw, lib.data, hideAdult, m => !!m.adult) const personalPool = filterToMissing(personalRaw, lib.data, hideAdult, m => !!m.adult)
.filter(m => m.backdrop_path && m.overview) .filter(m => m.backdrop_path && m.overview)
if (personalPool.length > 0) { if (personalPool.length > 0) {
@@ -84,7 +85,7 @@ export default function TonightHero({ kind }: Props) {
if (!pick) return null if (!pick) return null
const item = pick.item const item = pick.item as TmdbDiscoverItem
const title: string = item.title || item.name || '' const title: string = item.title || item.name || ''
const overview: string = item.overview || '' const overview: string = item.overview || ''
const backdrop = `${TMDB_IMG}/w1280${item.backdrop_path}` const backdrop = `${TMDB_IMG}/w1280${item.backdrop_path}`
+2 -2
View File
@@ -87,7 +87,7 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
limit: 10, limit: 10,
recursive: true, recursive: true,
includeItemTypes: ['Movie'], includeItemTypes: ['Movie'],
fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags'], fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags'] as any[],
}), }),
getItemsApi(api!).getItems({ getItemsApi(api!).getItems({
userId: auth.userId, userId: auth.userId,
@@ -96,7 +96,7 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
limit: 50, limit: 50,
recursive: true, recursive: true,
includeItemTypes: ['Episode'], includeItemTypes: ['Episode'],
fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags', 'SeriesName', 'SeriesId', 'SeriesPrimaryImageTag', 'ParentIndexNumber', 'IndexNumber'], fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags', 'SeriesName', 'SeriesId', 'SeriesPrimaryImageTag', 'ParentIndexNumber', 'IndexNumber'] as any[],
}), }),
]) ])
const movies = movieRes.data.Items || [] const movies = movieRes.data.Items || []
+1 -1
View File
@@ -34,7 +34,7 @@ interface Props {
volume: number volume: number
loopA: number | null loopA: number | null
loopB: number | null loopB: number | null
chapters: { StartPositionTicks: number; Name?: string | null; ImageTag?: string | null }[] chapters: { StartPositionTicks?: number | null; Name?: string | null; ImageTag?: string | null }[]
bookmarksRefreshKey: number bookmarksRefreshKey: number
previousItem: BaseItemDto | null | undefined previousItem: BaseItemDto | null | undefined
nextItem: BaseItemDto | null | undefined nextItem: BaseItemDto | null | undefined
+1 -1
View File
@@ -11,7 +11,7 @@ import SyncPlayPanel from './SyncPlayPanel'
import type { BaseItemDto } from '../../api/types' import type { BaseItemDto } from '../../api/types'
export interface ChapterMarker { export interface ChapterMarker {
StartPositionTicks: number StartPositionTicks?: number | null
Name?: string | null Name?: string | null
ImageTag?: string | null ImageTag?: string | null
} }
+1 -1
View File
@@ -163,7 +163,7 @@ export default function RequestModal({ open, onClose, tmdbId, kind, tmdbData }:
monitored, monitored,
searchOnAdd, searchOnAdd,
title: match.title, title: match.title,
titleSlug: match.titleSlug || '', titleSlug: (match.title || '').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
year: match.year || 0, year: match.year || 0,
images: match.images, images: match.images,
minimumAvailability: 'released', minimumAvailability: 'released',
+1 -1
View File
@@ -45,7 +45,7 @@ export default function PersonSpotlightRow({ personId, name, role, profilePath,
? (credits.crew || []).filter(c => c.job === 'Director') ? (credits.crew || []).filter(c => c.job === 'Director')
: (credits.cast || []) : (credits.cast || [])
// Drop trivia / archival appearances and ultra-deep cuts. // Drop trivia / archival appearances and ultra-deep cuts.
.filter(c => COMMERCIAL_DEPARTMENTS.includes(c.department || 'Acting')) .filter(c => COMMERCIAL_DEPARTMENTS.includes((c as { department?: string }).department || 'Acting'))
// De-dupe by id (a director may have multiple crew credits on one film // De-dupe by id (a director may have multiple crew credits on one film
// when they're also writer; a cast member may appear twice for episodic // when they're also writer; a cast member may appear twice for episodic
+1 -1
View File
@@ -205,7 +205,7 @@ export default function QuickLookModal() {
<RequestButton <RequestButton
tmdbId={numericTmdb} tmdbId={numericTmdb}
kind={item.Type === 'Movie' ? 'movie' : 'tv'} kind={item.Type === 'Movie' ? 'movie' : 'tv'}
tmdbData={tmdbData} tmdbData={(tmdbData || null) as (import('../../api/tmdb').TmdbMovie | import('../../api/tmdb').TmdbTvShow | null)}
/> />
)} )}
</div> </div>
+1 -1
View File
@@ -41,7 +41,7 @@ export function usePrebuffer(item: BaseItemDto | null | undefined, armed: boolea
playbackInfoDto: { playbackInfoDto: {
UserId: jellyfinClient.getAuthState()!.userId, UserId: jellyfinClient.getAuthState()!.userId,
MaxStreamingBitrate: 140_000_000, MaxStreamingBitrate: 140_000_000,
DeviceProfile: browserDeviceProfile(), DeviceProfile: browserDeviceProfile() as any,
AutoOpenLiveStream: true, AutoOpenLiveStream: true,
EnableDirectPlay: true, EnableDirectPlay: true,
EnableDirectStream: true, EnableDirectStream: true,
+1 -1
View File
@@ -49,7 +49,7 @@ export async function startDownload(args: {
} }
} }
const blob = new Blob(chunks) const blob = new Blob(chunks as BlobPart[])
const objectUrl = URL.createObjectURL(blob) const objectUrl = URL.createObjectURL(blob)
store.update(dl.id, { store.update(dl.id, {
status: 'done', status: 'done',
+6 -2
View File
@@ -250,7 +250,9 @@ export async function addToTraktWatchlist(item: BaseItemDto): Promise<boolean> {
if (!token) return false if (!token) return false
const body = await buildScrobbleBody(item, 0) const body = await buildScrobbleBody(item, 0)
if (!body) return false if (!body) return false
const payload = item.Type === 'Movie' ? { movies: [{ ids: body.movie.ids }] } : { shows: [{ ids: body.show.ids }] } const payload = item.Type === 'Movie'
? { movies: [{ ids: (body as { movie: { ids: Record<string, string | number> } }).movie.ids }] }
: { shows: [{ ids: (body as { show: { ids: Record<string, string | number> } }).show.ids }] }
try { try {
const res = await fetch(`${BASE}/sync/watchlist`, { const res = await fetch(`${BASE}/sync/watchlist`, {
method: 'POST', method: 'POST',
@@ -269,7 +271,9 @@ export async function removeFromTraktWatchlist(item: BaseItemDto): Promise<boole
if (!token) return false if (!token) return false
const body = await buildScrobbleBody(item, 0) const body = await buildScrobbleBody(item, 0)
if (!body) return false if (!body) return false
const payload = item.Type === 'Movie' ? { movies: [{ ids: body.movie.ids }] } : { shows: [{ ids: body.show.ids }] } const payload = item.Type === 'Movie'
? { movies: [{ ids: (body as { movie: { ids: Record<string, string | number> } }).movie.ids }] }
: { shows: [{ ids: (body as { show: { ids: Record<string, string | number> } }).show.ids }] }
try { try {
const res = await fetch(`${BASE}/sync/watchlist/remove`, { const res = await fetch(`${BASE}/sync/watchlist/remove`, {
method: 'POST', method: 'POST',
+6 -3
View File
@@ -28,7 +28,10 @@ export default function PersonPage() {
const filmography = useMemo(() => { const filmography = useMemo(() => {
const cast = (person?.combined_credits?.cast || []).map(c => ({ ...c, _kind: 'cast' as const })) const cast = (person?.combined_credits?.cast || []).map(c => ({ ...c, _kind: 'cast' as const }))
const crew = (person?.combined_credits?.crew || []).map(c => ({ ...c, _kind: 'crew' as const })) const crew = (person?.combined_credits?.crew || []).map(c => ({ ...c, _kind: 'crew' as const }))
let merged: ((TmdbCombinedCreditCast | TmdbCombinedCreditCrew) & { _kind: 'cast' | 'crew' })[] = [ type MergedCredit =
| (TmdbCombinedCreditCast & { _kind: 'cast' })
| (TmdbCombinedCreditCrew & { _kind: 'crew' })
let merged: MergedCredit[] = [
...cast, ...cast,
...crew, ...crew,
] ]
@@ -37,7 +40,7 @@ export default function PersonPage() {
if (filter === 'Acting') { if (filter === 'Acting') {
merged = merged.filter(c => c._kind === 'cast') merged = merged.filter(c => c._kind === 'cast')
} else { } else {
merged = merged.filter(c => c._kind === 'crew' && c.department === filter) merged = merged.filter((c): c is MergedCredit & { _kind: 'crew' } => c._kind === 'crew' && c.department === filter)
} }
} }
@@ -260,7 +263,7 @@ export default function PersonPage() {
{title} {title}
</p> </p>
<p className="text-[11px] text-text-3 truncate leading-tight mt-0.5"> <p className="text-[11px] text-text-3 truncate leading-tight mt-0.5">
{c.character ? c.character : c.job} {c.character ? c.character : (c as TmdbCombinedCreditCrew).job}
{year && ( {year && (
<> <>
<span className="text-text-5 mx-1">·</span> <span className="text-text-5 mx-1">·</span>
+3 -2
View File
@@ -345,7 +345,7 @@ export default function PlayerPage() {
const fromQueue = searchParams.get('resume') === 'true' const fromQueue = searchParams.get('resume') === 'true'
if (showResumePromptPref && !fromQueue && pos > threshold) { if (showResumePromptPref && !fromQueue && pos > threshold) {
setResumePromptOpen(true) setResumePromptOpen(true)
resumePromptShownRef.current = item.Id resumePromptShownRef.current = item.Id ?? null
} }
}, [item?.Id, searchParams, showResumePromptPref]) }, [item?.Id, searchParams, showResumePromptPref])
@@ -986,7 +986,8 @@ export default function PlayerPage() {
if (resolvedSource?.SupportsDirectPlay) { if (resolvedSource?.SupportsDirectPlay) {
const el = (playerRef.current as { el?: HTMLElement } | null)?.el const el = (playerRef.current as { el?: HTMLElement } | null)?.el
const video = el?.querySelector('video') as HTMLVideoElement | null const video = el?.querySelector('video') as HTMLVideoElement | null
const native = video.audioTracks as { length: number; [i: number]: { enabled: boolean } } | undefined if (!video) return
const native = video.audioTracks as { length: number; [i: number]: { enabled: boolean; language?: string } } | undefined
if (native && native.length > 1) { if (native && native.length > 1) {
const target = audioTracks.find(t => t.Index === jfIndex) const target = audioTracks.find(t => t.Index === jfIndex)
const targetLang = (target?.Language || '').toLowerCase() const targetLang = (target?.Language || '').toLowerCase()
+3 -2
View File
@@ -8,6 +8,7 @@ import { useTmdbDetailEnrichment } from '../hooks/use-tmdb-detail'
import { useLibraryByTmdbId } from '../hooks/use-jellyfin' import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
import { useFanartMovie, useFanartTv } from '../hooks/use-external' import { useFanartMovie, useFanartTv } from '../hooks/use-external'
import { getTmdbImageUrl, pickTmdbLogo } from '../api/tmdb' import { getTmdbImageUrl, pickTmdbLogo } from '../api/tmdb'
import type { TmdbMovie, TmdbTvShow } from '../api/tmdb'
import { pickBestFanartImage } from '../api/fanart' import { pickBestFanartImage } from '../api/fanart'
import { mapTmdbToJf } from '../lib/tmdb-mapping' import { mapTmdbToJf } from '../lib/tmdb-mapping'
import ContentRow from '../components/ui/ContentRow' import ContentRow from '../components/ui/ContentRow'
@@ -262,7 +263,7 @@ export default function TmdbDetailPage({ tmdbId, kind }: Props) {
</span> </span>
</> </>
)} )}
{kind === 'tv' && data.number_of_seasons > 0 && ( {kind === 'tv' && (data.number_of_seasons ?? 0) > 0 && (
<> <>
<Dot /> <Dot />
<span className="inline-flex items-center gap-1 tabular-nums"> <span className="inline-flex items-center gap-1 tabular-nums">
@@ -287,7 +288,7 @@ export default function TmdbDetailPage({ tmdbId, kind }: Props) {
)} )}
<div className="flex items-center gap-2.5 flex-wrap"> <div className="flex items-center gap-2.5 flex-wrap">
<RequestButton tmdbId={tmdbId} kind={kind} tmdbData={data} /> <RequestButton tmdbId={tmdbId} kind={kind} tmdbData={data as TmdbMovie | TmdbTvShow | null} />
{matchedLocal && ( {matchedLocal && (
<button <button
onClick={() => navigate(`/item/${matchedLocal.id}`)} onClick={() => navigate(`/item/${matchedLocal.id}`)}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from '../../hooks/use-tmdb' } from '../../hooks/use-tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping' import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import type { TmdbDiscoverItem } from '../../api/tmdb'
import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres' import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres'
import ContentRow from '../../components/ui/ContentRow' import ContentRow from '../../components/ui/ContentRow'
import { usePreferencesStore } from '../../stores/preferences-store' import { usePreferencesStore } from '../../stores/preferences-store'
@@ -253,7 +254,7 @@ export function GenreRow({ genre }: { genre: { label: string; subtitle: string }
const items = useMemo(() => { const items = useMemo(() => {
const m = (movies.data?.results || []).map(x => ({ ...x, media_type: 'movie' })) const m = (movies.data?.results || []).map(x => ({ ...x, media_type: 'movie' }))
const t = (tv.data?.results || []).map(x => ({ ...x, media_type: 'tv' })) const t = (tv.data?.results || []).map(x => ({ ...x, media_type: 'tv' }))
const out: { id: number; media_type: string }[] = [] const out: TmdbDiscoverItem[] = []
const max = Math.max(m.length, t.length) const max = Math.max(m.length, t.length)
for (let i = 0; i < max && out.length < 20; i++) { for (let i = 0; i < max && out.length < 20; i++) {
if (m[i]) out.push(m[i]) if (m[i]) out.push(m[i])
@@ -37,7 +37,7 @@ export function ServerDashboardSection() {
const transcodes = activeSessions.filter(s => s.PlayState?.PlayMethod === 'Transcode') const transcodes = activeSessions.filter(s => s.PlayState?.PlayMethod === 'Transcode')
const uptime = useMemo(() => { const uptime = useMemo(() => {
const start = info?.ServerStartTime const start = (info as { ServerStartTime?: string } | null | undefined)?.ServerStartTime
if (!start) return null if (!start) return null
const ms = Date.now() - new Date(start).getTime() const ms = Date.now() - new Date(start).getTime()
const days = Math.floor(ms / 86_400_000) const days = Math.floor(ms / 86_400_000)
+32
View File
@@ -0,0 +1,32 @@
/* ── Browser API extensions ── */
export {}
declare global {
interface Navigator {
mediaCapabilities?: {
decodingInfo(configuration: unknown): Promise<unknown>
}
}
interface VideoPlaybackQuality {
droppedVideoFrames: number
}
interface HTMLVideoElement {
getVideoPlaybackQuality?(): VideoPlaybackQuality
audioTracks?: { length: number; [i: number]: { enabled: boolean; language?: string } }
mozPreservesPitch?: boolean
webkitPreservesPitch?: boolean
}
interface Document {
pictureInPictureElement?: Element | null
exitPictureInPicture?(): Promise<void>
}
interface Window {
webkitAudioContext?: typeof AudioContext
SubtitlesOctopus?: unknown
}
}
-29
View File
@@ -9,35 +9,6 @@ interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv
} }
/* ── Browser API extensions ── */
interface Navigator {
mediaCapabilities?: {
decodingInfo(configuration: unknown): Promise<unknown>
}
}
interface VideoPlaybackQuality {
droppedVideoFrames: number
}
interface HTMLVideoElement {
getVideoPlaybackQuality?(): VideoPlaybackQuality
audioTracks?: { length: number; [i: number]: { enabled: boolean } }
mozPreservesPitch?: boolean
webkitPreservesPitch?: boolean
}
interface Document {
pictureInPictureElement?: Element | null
exitPictureInPicture?(): Promise<void>
}
interface Window {
webkitAudioContext?: typeof AudioContext
SubtitlesOctopus?: unknown
}
/* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/no-empty-object-type */
declare module 'react' { declare module 'react' {
interface CSSProperties { interface CSSProperties {