diff --git a/src/pages/discover/chrome.tsx b/src/pages/discover/chrome.tsx
new file mode 100644
index 0000000..aaf0eee
--- /dev/null
+++ b/src/pages/discover/chrome.tsx
@@ -0,0 +1,58 @@
+import { Compass } from '../../lib/icons'
+
+export function NoKey() {
+ return (
+
+
+
+ Discover needs a TMDB key
+
+
+ This page surfaces films and shows you don't already own. It pulls trending, top-rated, upcoming, by-genre, by-language, by-studio, and by-network rows from TMDB - all of which require an API key to load.
+
+
+ Open TMDB settings
+
+
+ )
+}
+
+export function Hero() {
+ return (
+
+
+
+
+ Discover
+
+
+
+ Find your next favorite
+
+
+ Films and shows you don't have yet, sourced from TMDB.
+
+
+ )
+}
+
+export function SectionHeader({ eyebrow, title, subtitle }: { eyebrow: string; title: string; subtitle: string }) {
+ return (
+
+
+
+ {eyebrow}
+
+
{title}
+
{subtitle}
+
+ )
+}
diff --git a/src/pages/discover/filtered-grid.tsx b/src/pages/discover/filtered-grid.tsx
new file mode 100644
index 0000000..7377ddd
--- /dev/null
+++ b/src/pages/discover/filtered-grid.tsx
@@ -0,0 +1,91 @@
+import { useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb'
+import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
+import { mapTmdbToJf } from '../../lib/tmdb-mapping'
+import PosterCard from '../../components/ui/PosterCard'
+import { usePosterGridClasses } from '../../lib/density'
+import { filtersToTmdbParams, type DiscoverFilterState } from '../../components/discover/DiscoverFilters'
+import type { BaseItemDto } from '../../api/types'
+
+/**
+ * Shown when any filter is active or sort has been changed from the default.
+ * Renders a single grid of TMDB results constrained by the filter selections,
+ * with optional library-hide.
+ */
+export function FilteredGrid({ filters }: { filters: DiscoverFilterState }) {
+ const navigate = useNavigate()
+ const lib = useLibraryByTmdbId()
+ const grid = usePosterGridClasses()
+ const params = useMemo(() => filtersToTmdbParams(filters), [filters])
+ const moviesQ = useTmdbDiscoverMovies(filters.kind === 'movie' ? params : ({} as Record))
+ const tvQ = useTmdbDiscoverTv(filters.kind === 'tv' ? params : ({} as Record))
+ const data = filters.kind === 'movie' ? moviesQ.data : tvQ.data
+ const isLoading = filters.kind === 'movie' ? moviesQ.isLoading : tvQ.isLoading
+
+ const items = useMemo(() => {
+ const raw = (data?.results || []).map(m => ({
+ ...m,
+ media_type: filters.kind === 'movie' ? 'movie' : 'tv',
+ }))
+ let list = raw
+ if (filters.hideOwned && lib.data) {
+ list = list.filter(m => !lib.data!.has(String(m.id)))
+ }
+ return mapTmdbToJf(list, lib.data) as BaseItemDto[]
+ }, [data, filters.kind, filters.hideOwned, lib.data])
+
+ return (
+
+
+
+ {isLoading ? (
+ 'Searching...'
+ ) : (
+ <>
+ {items.length}
+ matching {filters.kind === 'movie' ? 'movies' : 'shows'}
+ {filters.hideOwned && (
+ (excluding your library)
+ )}
+ >
+ )}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ ) : items.length === 0 ? (
+
+
+ No matches
+
+
+ Loosen the filters to see more results. Reduce the minimum rating, expand the year range, or clear genre selections.
+
+
+ ) : (
+
+ {items.map((item, i) => (
+
item.Id && navigate(`/item/${item.Id}`)}
+ />
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/pages/discover/helpers.ts b/src/pages/discover/helpers.ts
new file mode 100644
index 0000000..38ec140
--- /dev/null
+++ b/src/pages/discover/helpers.ts
@@ -0,0 +1,41 @@
+/**
+ * Filter raw TMDB results down to ones the user does NOT own. The library
+ * map keys items by their TMDB id; we drop anything that hits. When no
+ * library map has loaded yet, returns an empty array so rows hide rather
+ * than briefly showing items the user already owns.
+ */
+export function filterToMissing(
+ raw: T[],
+ lib?: Map | null,
+ hideAdult = false,
+ adultFlag?: (m: T) => boolean,
+): T[] {
+ if (!lib) return []
+ let list = raw.filter(m => !lib.has(String(m.id)))
+ if (hideAdult && adultFlag) list = list.filter(m => !adultFlag(m))
+ return list
+}
+
+export const GENRE_ROWS: Array<{ label: string; subtitle: string }> = [
+ { label: 'Action', subtitle: 'Bullets, fists, explosions' },
+ { label: 'Drama', subtitle: 'Stories that hit deep' },
+ { label: 'Comedy', subtitle: 'Light watching' },
+ { label: 'Science Fiction', subtitle: 'Beyond the possible' },
+ { label: 'Thriller', subtitle: 'Edge-of-your-seat tension' },
+ { label: 'Horror', subtitle: 'Lights off' },
+ { label: 'Animation', subtitle: 'Drawn to perfection' },
+ { label: 'Romance', subtitle: 'Love and longing' },
+ { label: 'Fantasy', subtitle: 'Worlds of magic' },
+ { label: 'Mystery', subtitle: 'Puzzles to solve' },
+]
+
+export const LANGUAGE_ROWS: Array<{ code: string; label: string; subtitle: string }> = [
+ { code: 'ja', label: 'Japanese cinema', subtitle: 'From Kurosawa to Kore-eda' },
+ { code: 'ko', label: 'Korean cinema', subtitle: 'New wave thrillers and drama' },
+ { code: 'fr', label: 'French cinema', subtitle: 'Auteurs and arthouse' },
+ { code: 'es', label: 'Spanish-language cinema', subtitle: 'Spain and Latin America' },
+ { code: 'de', label: 'German cinema', subtitle: 'Berlin school and beyond' },
+ { code: 'it', label: 'Italian cinema', subtitle: 'Neo-realism to giallo' },
+ { code: 'zh', label: 'Chinese-language cinema', subtitle: 'Mainland, Hong Kong, Taiwan' },
+ { code: 'hi', label: 'Hindi cinema', subtitle: 'Bollywood at its best' },
+]
diff --git a/src/pages/discover/rows.tsx b/src/pages/discover/rows.tsx
new file mode 100644
index 0000000..20fd3b3
--- /dev/null
+++ b/src/pages/discover/rows.tsx
@@ -0,0 +1,337 @@
+import { useMemo } from 'react'
+import {
+ useTmdbTrending,
+ useTmdbDiscoverMovies,
+ useTmdbDiscoverTv,
+ useTmdbUpcoming,
+ useTmdbTopRatedMovies,
+ useTmdbTopRatedTv,
+} from '../../hooks/use-tmdb'
+import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
+import { mapTmdbToJf } from '../../lib/tmdb-mapping'
+import { tmdbMovieGenreId, tmdbTvGenreId } from '../../lib/tmdb-genres'
+import ContentRow from '../../components/ui/ContentRow'
+import { usePreferencesStore } from '../../stores/preferences-store'
+import { filterToMissing } from './helpers'
+
+export function TrendingDayRow() {
+ const trending = useTmdbTrending('all', 'day')
+ const lib = useLibraryByTmdbId()
+ const hideAdult = usePreferencesStore(s => s.hideAdult)
+ const items = useMemo(() => {
+ const raw = (trending.data?.results || [])
+ const filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult)
+ return mapTmdbToJf(filtered, lib.data)
+ }, [trending.data, lib.data, hideAdult])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function TrendingWeekRow() {
+ const trending = useTmdbTrending('all', 'week')
+ const lib = useLibraryByTmdbId()
+ const hideAdult = usePreferencesStore(s => s.hideAdult)
+ const items = useMemo(() => {
+ const raw = (trending.data?.results || [])
+ const filtered = filterToMissing(raw, lib.data, hideAdult, m => !!m.adult)
+ return mapTmdbToJf(filtered, lib.data)
+ }, [trending.data, lib.data, hideAdult])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function PopularMoviesRow() {
+ const movies = useTmdbDiscoverMovies({ sort_by: 'popularity.desc' })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function PopularTvRow() {
+ const tv = useTmdbDiscoverTv({ sort_by: 'popularity.desc' })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (tv.data?.results || []).map(m => ({ ...m, media_type: 'tv' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [tv.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function UpcomingMoviesRow({ region }: { region: string }) {
+ const upcoming = useTmdbUpcoming(region)
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (upcoming.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [upcoming.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function TopRatedMoviesRow() {
+ const movies = useTmdbTopRatedMovies()
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function TopRatedTvRow() {
+ const tv = useTmdbTopRatedTv()
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (tv.data?.results || []).map(m => ({ ...m, media_type: 'tv' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [tv.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function CultClassicsRow() {
+ const movies = useTmdbDiscoverMovies({
+ 'vote_count.gte': '10000',
+ 'vote_average.gte': '8',
+ 'popularity.lte': '20',
+ sort_by: 'vote_average.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function HighlyRatedThisYearRow() {
+ const year = new Date().getFullYear()
+ const movies = useTmdbDiscoverMovies({
+ primary_release_year: String(year),
+ 'vote_count.gte': '200',
+ sort_by: 'vote_average.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function ForeignFilmsRow() {
+ const movies = useTmdbDiscoverMovies({
+ 'vote_count.gte': '500',
+ sort_by: 'vote_average.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || [])
+ .filter(m => m.original_language && m.original_language !== 'en')
+ .slice(0, 20)
+ .map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function DocumentariesRow() {
+ const movies = useTmdbDiscoverMovies({
+ with_genres: '99',
+ 'vote_count.gte': '300',
+ sort_by: 'vote_average.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function GenreRow({ genre }: { genre: { label: string; subtitle: string } }) {
+ const movieGenreId = tmdbMovieGenreId(genre.label)
+ const tvGenreId = tmdbTvGenreId(genre.label)
+ const movies = useTmdbDiscoverMovies(
+ movieGenreId
+ ? { with_genres: String(movieGenreId), 'vote_count.gte': '500', sort_by: 'vote_average.desc' }
+ : ({} as Record),
+ )
+ const tv = useTmdbDiscoverTv(
+ tvGenreId
+ ? { with_genres: String(tvGenreId), 'vote_count.gte': '200', sort_by: 'vote_average.desc' }
+ : ({} as Record),
+ )
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const m = (movies.data?.results || []).map(x => ({ ...x, media_type: 'movie' }))
+ const t = (tv.data?.results || []).map(x => ({ ...x, media_type: 'tv' }))
+ const out: { id: number; media_type: string }[] = []
+ const max = Math.max(m.length, t.length)
+ for (let i = 0; i < max && out.length < 20; i++) {
+ if (m[i]) out.push(m[i])
+ if (t[i]) out.push(t[i])
+ }
+ return mapTmdbToJf(filterToMissing(out, lib.data), lib.data)
+ }, [movies.data, tv.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function LanguageRow({ lang }: { lang: { code: string; label: string; subtitle: string } }) {
+ const movies = useTmdbDiscoverMovies({
+ with_original_language: lang.code,
+ 'vote_count.gte': '200',
+ sort_by: 'vote_average.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function StudioRow({ brandId, label, subtitle }: { brandId: number; label: string; subtitle: string }) {
+ const movies = useTmdbDiscoverMovies({
+ with_companies: String(brandId),
+ sort_by: 'popularity.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (movies.data?.results || []).slice(0, 18).map(m => ({ ...m, media_type: 'movie' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [movies.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+export function NetworkRow({ brandId, label, subtitle }: { brandId: number; label: string; subtitle: string }) {
+ const tv = useTmdbDiscoverTv({
+ with_networks: String(brandId),
+ sort_by: 'popularity.desc',
+ })
+ const lib = useLibraryByTmdbId()
+ const items = useMemo(() => {
+ const raw = (tv.data?.results || []).slice(0, 18).map(m => ({ ...m, media_type: 'tv' }))
+ return mapTmdbToJf(filterToMissing(raw, lib.data), lib.data)
+ }, [tv.data, lib.data])
+ if (items.length === 0) return null
+ return (
+
+ )
+}