'use client' import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import type { TvSeries, TvSeason, TvEpisode, RatingOperator } from '@/types' import { useDebounce } from '@/hooks/useDebounce' import FilterPanel from '@/components/FilterPanel' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import MediaTagPanel from '@/components/tags/MediaTagPanel' import TagSelector from '@/components/tags/TagSelector' import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import EpisodeCard from './EpisodeCard' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import { isBrowserPlayable } from '@/lib/browser-media' interface Props { libraryId: string readOnly?: boolean } type ViewLevel = 'series' | 'seasons' | 'episodes' export default function TvView({ libraryId, readOnly }: Props) { const [view, setView] = useState('series') const [series, setSeries] = useState([]) const [seasons, setSeasons] = useState([]) const [episodes, setEpisodes] = useState([]) const [selectedSeries, setSelectedSeries] = useState(null) const [selectedSeason, setSelectedSeason] = useState(null) const [playingEpisodeIndex, setPlayingEpisodeIndex] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) const [seriesEpisodeTags, setSeriesEpisodeTags] = useState>({}) const [ratingValue, setRatingValue] = useState(null) const [ratingOperator, setRatingOperator] = useState('gte') const debouncedSearch = useDebounce(search, 200) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState( () => typeof window !== 'undefined' && window.innerWidth >= 768 ) const [selectedSeriesIndex, setSelectedSeriesIndex] = useState(null) const [selectedSeasonIndex, setSelectedSeasonIndex] = useState(null) const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null) const [menuOpen, setMenuOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) const [refreshingMeta, setRefreshingMeta] = useState(false) const [editingMeta, setEditingMeta] = useState(false) const [savingMeta, setSavingMeta] = useState(false) const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' }) const [warnRefresh, setWarnRefresh] = useState(false) const [renaming, setRenaming] = useState(false) const [renameName, setRenameName] = useState('') const [renameError, setRenameError] = useState(null) const [renameSaving, setRenameSaving] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollItems, setDoomScrollItems] = useState([]) const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [showTagPanel, setShowTagPanel] = useState(false) const [tagPanelItemKey, setTagPanelItemKey] = useState(null) const [tagPanelDisabled, setTagPanelDisabled] = useState(false) const [tagRefreshKey, setTagRefreshKey] = useState(0) const menuRef = useRef(null) const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' const toggleTag = (tagId: string) => setSelectedTagIds((prev) => { const next = new Set(prev) next.has(tagId) ? next.delete(tagId) : next.add(tagId) return next }) const fetchSeries = useCallback(() => { setLoading(true) setError(null) fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) .then((data) => { setSeries(data); setLoading(false) }) .catch(() => { setError('Failed to load TV library'); setLoading(false) }) }, [libraryId]) useEffect(() => { fetchSeries() }, [fetchSeries]) const fetchAssignments = useCallback(() => { fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) .then(setAssignments) .catch(() => {}) }, [libraryId]) useEffect(() => { fetchAssignments() }, [fetchAssignments]) const fetchSeriesEpisodeTags = useCallback(() => { fetch(`/api/tv/series-episode-tags?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) .then(setSeriesEpisodeTags) .catch(() => {}) }, [libraryId]) useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags]) const openSeries = (s: TvSeries) => { setSelectedSeriesIndex(filteredSeries.indexOf(s)) setSelectedSeries(s) setView('seasons') setLoading(true) setError(null) fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`) .then((r) => r.json()) .then((data: TvSeason[]) => { setSeasons(data) setLoading(false) }) .catch(() => { setError('Failed to load seasons'); setLoading(false) }) } const openSeason = (season: TvSeason, index?: number) => { setSelectedSeasonIndex(index ?? seasons.indexOf(season)) setSelectedSeason(season) setView('episodes') if (showTagPanel) { setTagPanelDisabled(true) } setLoading(true) setError(null) fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(season.seriesId)}&seasonId=${encodeURIComponent(season.id)}` ) .then((r) => r.json()) .then((data) => { setEpisodes(data); setLoading(false) }) .catch(() => { setError('Failed to load episodes'); setLoading(false) }) } // Close menu on outside click useEffect(() => { if (!menuOpen) return const handler = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setMenuOpen(false) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [menuOpen]) const goToSeries = () => { setView('series') setSelectedSeries(null) setSelectedSeason(null) setSelectedSeriesIndex(null) setSelectedSeasonIndex(null) setMenuOpen(false) setConfirming(false) setShowTagPanel(false) setTagPanelItemKey(null) setTagPanelDisabled(false) } const goToSeasons = () => { setView('seasons') setSelectedSeason(null) setSelectedSeasonIndex(null) setConfirming(false) if (showTagPanel && selectedSeries?.item_key) { setTagPanelItemKey(selectedSeries.item_key) setTagPanelDisabled(false) } } const handleDeleteSeries = () => { if (!selectedSeries) return setDeleting(true) fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`, { method: 'DELETE' } ) .then(() => { setSeries((prev) => prev.filter((s) => s.id !== selectedSeries.id)) goToSeries() setDeleting(false) }) .catch(() => setDeleting(false)) } const doRefreshSeriesMetadata = () => { if (!selectedSeries) return setRefreshingMeta(true) setWarnRefresh(false) const itemKey = `${libraryId}:tv_series:${selectedSeries.id}` const currentId = selectedSeries.id fetch( `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}&includeEpisodes=true`, { method: 'POST' } ) .then(() => fetch(`/api/tv?libraryId=${encodeURIComponent(libraryId)}`)) .then((r) => r.json()) .then((data: TvSeries[]) => { setSeries(data) const updated = data.find((s) => s.id === currentId) if (updated) setSelectedSeries(updated) }) .finally(() => setRefreshingMeta(false)) } const handleRefreshSeriesMetadata = () => { setMenuOpen(false) if (selectedSeries?.manuallyEdited) { setWarnRefresh(true) } else { doRefreshSeriesMetadata() } } const handleStartEditingMeta = () => { if (!selectedSeries) return setMenuOpen(false) setEditForm({ title: selectedSeries.title, year: selectedSeries.year?.toString() ?? '', plot: selectedSeries.plot ?? '', genres: selectedSeries.genres.join(', '), }) setEditingMeta(true) } const handleSaveSeriesMetadata = () => { if (!selectedSeries) return setSavingMeta(true) const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean) const yearNum = editForm.year ? parseInt(editForm.year, 10) : null fetch('/api/metadata', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemKey: selectedSeries.item_key, title: editForm.title, year: isNaN(yearNum as number) ? null : yearNum, plot: editForm.plot || null, genres, }), }) .then(() => { setEditingMeta(false); fetchSeries() }) .finally(() => setSavingMeta(false)) } const handleStartRename = () => { if (!selectedSeries) return setMenuOpen(false) setRenameName(decodeURIComponent(selectedSeries.id)) setRenameError(null) setRenaming(true) } const handleRename = () => { if (!selectedSeries) return const trimmed = renameName.trim() if (!trimmed) return setRenameSaving(true) setRenameError(null) fetch('/api/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(selectedSeries.id), newName: trimmed, itemType: 'tv_series', }), }) .then(async (res) => { if (res.status === 409) { const data = await res.json() setRenameError(data.error) return } if (!res.ok) throw new Error() setRenaming(false) fetchSeries() }) .catch(() => setRenameError('Rename failed')) .finally(() => setRenameSaving(false)) } const handleDoomScroll = async () => { setDoomScrollLoading(true) try { let items: DoomScrollItem[] if (filtersActive && filteredSeries.length < series.length) { // Fetch episodes only from the filtered series const episodeLists = await Promise.all( filteredSeries.map(async (s) => { const seasons: TvSeason[] = await fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}` ).then((r) => r.json()) const seasonEps = await Promise.all( seasons.map((season) => fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}` ).then((r) => r.json() as Promise) ) ) return seasonEps.flat() }) ) items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({ url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, name: ep.title, mediaType: 'video' as const, })) } else { // No filters — fetch all episodes via the TV API hierarchy const allSeries: TvSeries[] = await fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}` ).then((r) => r.json()) const episodeLists = await Promise.all( allSeries.map(async (s) => { const seasons: TvSeason[] = await fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}` ).then((r) => r.json()) const seasonEps = await Promise.all( seasons.map((season) => fetch( `/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}` ).then((r) => r.json() as Promise) ) ) return seasonEps.flat() }) ) items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({ url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, name: ep.title, mediaType: 'video' as const, })) } setDoomScrollItems(items) setDoomScrollActive(true) } catch { // ignore } finally { setDoomScrollLoading(false) } } // Escape key + body scroll lock when modal is open useEffect(() => { if (view === 'series') return const handleKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return if (menuOpen) { setMenuOpen(false); return } if (showTagPanel) { setShowTagPanel(false); return } if (view === 'episodes') { setView('seasons') setSelectedSeason(null) setConfirming(false) if (selectedSeries?.item_key) { setTagPanelItemKey(selectedSeries.item_key) setTagPanelDisabled(false) } return } setView('series') setSelectedSeries(null) setSelectedSeason(null) setMenuOpen(false) setConfirming(false) setShowTagPanel(false) setTagPanelItemKey(null) setTagPanelDisabled(false) } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } }, [view, menuOpen, showTagPanel, selectedSeries]) const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null const handleRatingChange = (value: number | null, operator: RatingOperator) => { if (value === ratingValue && operator === ratingOperator) { setRatingValue(null) } else { setRatingValue(value) setRatingOperator(operator) } } const filteredSeries = useMemo(() => series.filter((s) => { if (debouncedSearch) { const q = debouncedSearch.toLowerCase() if (![s.title, s.plot, s.aiDescription, s.extractedText, s.extractedTextTranslated] .some((f) => f?.toLowerCase().includes(q))) return false } if (selectedTagIds.size > 0) { const seriesTags = assignments[s.item_key!] ?? [] const episodeTags = seriesEpisodeTags[s.id] ?? [] const allTags = [...new Set([...seriesTags, ...episodeTags])] if (![...selectedTagIds].every((id) => allTags.includes(id))) return false } if (ratingValue !== null) { const r = s.userRating if (r === null) return false if (ratingOperator === 'gte' && r < ratingValue) return false if (ratingOperator === 'eq' && r !== ratingValue) return false if (ratingOperator === 'lte' && r > ratingValue) return false } return true }), [series, debouncedSearch, selectedTagIds, assignments, seriesEpisodeTags, ratingValue, ratingOperator]) const filteredEpisodes = useMemo(() => episodes.filter((ep) => { if (debouncedSearch) { const q = debouncedSearch.toLowerCase() if (![ep.title, ep.plot, ep.aiDescription, ep.extractedText, ep.extractedTextTranslated] .some((f) => f?.toLowerCase().includes(q))) return false } if (selectedTagIds.size > 0) { const epTags = assignments[ep.item_key!] ?? [] if (![...selectedTagIds].every((id) => epTags.includes(id))) return false } if (ratingValue !== null) { const r = ep.userRating if (r === null) return false if (ratingOperator === 'gte' && r < ratingValue) return false if (ratingOperator === 'eq' && r !== ratingValue) return false if (ratingOperator === 'lte' && r > ratingValue) return false } return true }), [episodes, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator]) // Arrow key navigation for series/season levels (mirrors the prev/next UI buttons) useEffect(() => { if (view === 'series') return const handleArrowKey = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') { if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex > 0) openSeries(filteredSeries[selectedSeriesIndex - 1]) else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex > 0) openSeason(seasons[selectedSeasonIndex - 1], selectedSeasonIndex - 1) } if (e.key === 'ArrowRight') { if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1) openSeries(filteredSeries[selectedSeriesIndex + 1]) else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1) openSeason(seasons[selectedSeasonIndex + 1], selectedSeasonIndex + 1) } } document.addEventListener('keydown', handleArrowKey) return () => document.removeEventListener('keydown', handleArrowKey) // eslint-disable-next-line react-hooks/exhaustive-deps }, [view, selectedSeriesIndex, selectedSeasonIndex, filteredSeries, seasons]) const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null if (playingEpisode && playingEpisodeIndex !== null) { const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(playingEpisode.videoPath)}` return ( { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} onClose={() => setPlayingEpisodeIndex(null)} onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined} onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined} context="tv" readOnly={readOnly} /> ) } return (
{doomScrollActive && doomScrollItems.length > 0 && ( setDoomScrollActive(false)} /> )} {/* Breadcrumb */}
{view !== 'series' ? ( ) : ( All Series )} {selectedSeries && ( <> / {view === 'episodes' ? ( ) : ( {selectedSeries.title} )} )} {selectedSeason && ( <> / {selectedSeason.title} )}
{view === 'series' && ( <>
{showFilters && (
)}
{loading ? ( ) : error ? ( ) : series.length === 0 ? (

No TV shows found

Each series should be a folder containing season subdirectories with video files.

) : (
{filteredSeries.map((s) => (
openSeries(s)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSeries(s) } }} className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2 cursor-pointer" style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} onMouseEnter={(e) => { ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' ;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)' }} onMouseLeave={(e) => { ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)' ;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)' }} >
{s.posterUrl ? ( // eslint-disable-next-line @next/next/no-img-element {s.title} ) : (
📺
)}

{s.title}

{s.year ? `${s.year} · ` : ''}{s.seasonCount} season{s.seasonCount !== 1 ? 's' : ''}

))}
)}
{tagPanel && (
{ if (e.target === e.currentTarget) setTagPanel(null) }} >

Tags

{tagPanel.title}

{ setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} />
)} )} {(view === 'seasons' || view === 'episodes') && (
e.stopPropagation()} > {view === 'episodes' && (
· {selectedSeason?.title}
)} {view === 'seasons' && selectedSeries && (
{/* Series info header */}
{selectedSeries.posterUrl && ( // eslint-disable-next-line @next/next/no-img-element {selectedSeries.title} )}

{selectedSeries.title}

{/* Kebab menu */}
{menuOpen && (
)}
{/* Rename inline input */} {renaming && (
setRenameName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }} className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0" style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} autoFocus />
{renameError &&

{renameError}

}
)} {editingMeta ? (
setEditForm((f) => ({ ...f, title: e.target.value }))} className="w-full px-3 py-1.5 rounded-lg text-sm" style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} autoFocus />
setEditForm((f) => ({ ...f, year: e.target.value }))} className="w-full px-3 py-1.5 rounded-lg text-sm" style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} />