'use client' import { useEffect, useRef, useState, useCallback } from 'react' import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { DirectoryListing } from '@/types' import FilterPanel from '@/components/FilterPanel' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import TagSelector from '@/components/tags/TagSelector' import EpisodeCard from './EpisodeCard' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' interface Props { libraryId: string } type ViewLevel = 'series' | 'seasons' | 'episodes' export default function TvView({ libraryId }: 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 [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) const [tagPanel, setTagPanel] = useState<{ mediaKey: 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 [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollItems, setDoomScrollItems] = useState([]) const [doomScrollLoading, setDoomScrollLoading] = useState(false) const menuRef = useRef(null) 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) => { 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) // Flat series: a single synthetic season (id='.') means episodes live // directly in the series folder — skip the seasons screen automatically. if (data.length === 1 && data[0].id === '.') { openSeason(data[0]) } }) .catch(() => { setError('Failed to load seasons'); setLoading(false) }) } const openSeason = (season: TvSeason) => { setSelectedSeason(season) setView('episodes') 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) setMenuOpen(false) setConfirming(false) } const goToSeasons = () => { setView('seasons') setSelectedSeason(null) setConfirming(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 handleRefreshSeriesMetadata = () => { if (!selectedSeries) return setRefreshingMeta(true) setMenuOpen(false) const itemKey = `${libraryId}:tv_series:${selectedSeries.id}` fetch( `/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`, { method: 'POST' } ) .then(() => fetchSeries()) .finally(() => setRefreshingMeta(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().map((ep) => ({ url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, name: ep.title, mediaType: 'video' as const, })) } else { // No filters — use full recursive browse const data: DirectoryListing = await fetch( `/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true` ).then((r) => r.json()) items = data.entries .filter((e) => e.type === 'file' && e.mediaType === 'video' && e.url) .map((e) => ({ url: e.url!, name: e.name, mediaType: 'video' as const })) } setDoomScrollItems(items) setDoomScrollActive(true) } catch { // ignore } finally { setDoomScrollLoading(false) } } const filtersActive = search !== '' || selectedTagIds.size > 0 const filteredSeries = series.filter((s) => { if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0) { const seriesTags = assignments[`${libraryId}:${s.id}`] ?? [] const episodeTags = seriesEpisodeTags[s.id] ?? [] const allTags = seriesTags.length === 0 ? episodeTags : episodeTags.length === 0 ? seriesTags : [...new Set([...seriesTags, ...episodeTags])] if (![...selectedTagIds].every((id) => allTags.includes(id))) return false } return true }) const filteredEpisodes = episodes.filter((ep) => { if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0) { const epTags = assignments[`${libraryId}:${ep.id}`] ?? [] if (![...selectedTagIds].every((id) => epTags.includes(id))) return false } return true }) 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" /> ) } 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' : ''}

))}
)}
)} {view === 'seasons' && selectedSeries && (
{/* Series info header */}
{selectedSeries.posterUrl && ( // eslint-disable-next-line @next/next/no-img-element {selectedSeries.title} )}

{selectedSeries.title}

{/* Kebab menu */}
{menuOpen && (
)}
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
{selectedSeries.year && {selectedSeries.year}} {selectedSeries.genres.map((g) => ( {g} ))}
)} {selectedSeries.plot && (

{selectedSeries.plot}

)}
{/* Confirmation banner */} {confirming && (

Permanently delete this series and all its files?

)}
{loading ? ( ) : error ? ( ) : seasons.length === 0 ? (
No seasons found.
) : (
{seasons.map((season) => ( ))}
)}
)} {view === 'episodes' && selectedSeason && (
{loading ? ( ) : error ? ( ) : episodes.length === 0 ? (
No episodes found.
) : (
{filteredEpisodes.map((ep) => ( setPlayingEpisodeIndex(episodes.indexOf(ep))} onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} /> ))}
)}
)} {tagPanel && (
{ if (e.target === e.currentTarget) setTagPanel(null) }} >

Tags

{tagPanel.title}

{ setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} />
)}
) } function ErrorMsg({ message }: { message: string }) { return (
{message}
) } function SeriesLoadingGrid() { return (
{Array.from({ length: 12 }).map((_, i) => (
))}
) } function SeasonLoadingGrid() { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
) } function EpisodeLoadingGrid() { return (
{Array.from({ length: 8 }).map((_, i) => (
))}
) }