141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
import { useMemo } from 'react'
|
|
import ContentRow from '../ui/ContentRow'
|
|
import { useTmdbDiscoverMovies } from '../../hooks/use-tmdb'
|
|
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
|
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
|
import { filterToMissing } from '../../pages/discover/helpers'
|
|
import type { TmdbMovie } from '../../api/tmdb'
|
|
|
|
interface CanonicalList {
|
|
id: string
|
|
title: string
|
|
subtitle: string
|
|
params: Record<string, string>
|
|
/** Optional client-side filter applied on top of TMDB results. */
|
|
extra?: (m: TmdbMovie) => boolean
|
|
}
|
|
|
|
/**
|
|
* Hand-curated approximations of the canonical "you should have seen this"
|
|
* lists - AFI / Sight & Sound / Oscar winners - built from TMDB discover
|
|
* parameters rather than the volatile user-created /list endpoint.
|
|
*
|
|
* Each entry is one ContentRow. The rows self-hide via the existing
|
|
* filterToMissing pipeline when the user already owns everything in them.
|
|
*/
|
|
const LISTS: CanonicalList[] = [
|
|
{
|
|
id: 'best-picture-winners',
|
|
title: 'Best Picture winners',
|
|
subtitle: 'Academy Award winners across the decades',
|
|
params: {
|
|
// TMDB keyword 210024 = "academy award - best picture winner"
|
|
with_keywords: '210024',
|
|
sort_by: 'vote_average.desc',
|
|
'vote_count.gte': '500',
|
|
},
|
|
},
|
|
{
|
|
id: 'top-250',
|
|
title: 'The canonical 250',
|
|
subtitle: 'Films that have settled into the canon - massive vote count, top scores',
|
|
params: {
|
|
'vote_count.gte': '10000',
|
|
'vote_average.gte': '8',
|
|
sort_by: 'vote_average.desc',
|
|
},
|
|
},
|
|
{
|
|
id: 'highest-grossing',
|
|
title: 'Highest grossing of all time',
|
|
subtitle: 'The films that made everyone show up',
|
|
params: {
|
|
sort_by: 'revenue.desc',
|
|
'vote_count.gte': '500',
|
|
},
|
|
},
|
|
{
|
|
id: 'modern-classics',
|
|
title: 'Modern classics',
|
|
subtitle: 'Post-2000 films that already feel essential',
|
|
params: {
|
|
'primary_release_date.gte': '2000-01-01',
|
|
'vote_count.gte': '3000',
|
|
'vote_average.gte': '8',
|
|
sort_by: 'vote_average.desc',
|
|
},
|
|
},
|
|
{
|
|
id: 'foreign-canon',
|
|
title: 'Foreign cinema canon',
|
|
subtitle: 'Non-English films with critical pedigree',
|
|
params: {
|
|
'vote_count.gte': '1500',
|
|
'vote_average.gte': '7.8',
|
|
sort_by: 'vote_average.desc',
|
|
},
|
|
extra: m => !!m.original_language && m.original_language !== 'en',
|
|
},
|
|
{
|
|
id: 'animation-canon',
|
|
title: 'Animation canon',
|
|
subtitle: 'Highest-rated animated features across studios',
|
|
params: {
|
|
with_genres: '16',
|
|
'vote_count.gte': '2000',
|
|
'vote_average.gte': '7.5',
|
|
sort_by: 'vote_average.desc',
|
|
},
|
|
},
|
|
]
|
|
|
|
/**
|
|
* Section wrapper for the canonical-lists block. Only renders on the
|
|
* movies tab because the TMDB keywords + revenue sorts only make sense
|
|
* for films - TV equivalents would need different queries.
|
|
*/
|
|
export default function CanonicalLists() {
|
|
return (
|
|
<section className="pt-2 pb-2">
|
|
<div className="px-7 mb-5">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
|
<span className="text-[10.5px] font-semibold text-text-3 uppercase tracking-[0.18em]">
|
|
Canonical lists
|
|
</span>
|
|
</div>
|
|
<h2 className="font-display text-[20px] font-bold tracking-tight text-text-1 leading-tight">
|
|
The books and ballots
|
|
</h2>
|
|
<p className="text-[12px] text-text-3 mt-0.5">
|
|
Films that show up on every "best of" list - filtered to ones you don't own yet.
|
|
</p>
|
|
</div>
|
|
{LISTS.map(list => (
|
|
<CanonicalRow key={list.id} list={list} />
|
|
))}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function CanonicalRow({ list }: { list: CanonicalList }) {
|
|
const movies = useTmdbDiscoverMovies(list.params)
|
|
const lib = useLibraryByTmdbId()
|
|
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
|
const items = useMemo(() => {
|
|
let raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' as const }))
|
|
if (list.extra) raw = raw.filter(list.extra)
|
|
return mapTmdbToJf(filterToMissing(raw, lib.data, hideAdult, m => !!m.adult), lib.data)
|
|
}, [movies.data, lib.data, hideAdult, list])
|
|
if (items.length === 0) return null
|
|
return (
|
|
<ContentRow
|
|
title={list.title}
|
|
subtitle={list.subtitle}
|
|
items={items}
|
|
layoutKey={`canonical_${list.id}`}
|
|
/>
|
|
)
|
|
}
|