discover components
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLibraryGenreDistribution, useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||
import { useTmdbDiscoverMovies, useTmdbTopRatedMovies } from '../../hooks/use-tmdb'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||
import { tmdbMovieGenreId } from '../../lib/tmdb-genres'
|
||||
import { filterToMissing } from '../../pages/discover/helpers'
|
||||
import ContentRow from '../ui/ContentRow'
|
||||
|
||||
/**
|
||||
* Set of "interesting" genres we consider when looking for library
|
||||
* gaps. Limited to the ones that have a clear TMDB equivalent and are
|
||||
* reasonable to recommend in - "Music" or "TV Movie" are excluded
|
||||
* because suggestions there are usually noise.
|
||||
*/
|
||||
const INTERESTING_GENRES = [
|
||||
'Documentary',
|
||||
'Animation',
|
||||
'Horror',
|
||||
'Romance',
|
||||
'Thriller',
|
||||
'Science Fiction',
|
||||
'Mystery',
|
||||
'Fantasy',
|
||||
'Drama',
|
||||
'Comedy',
|
||||
'Action',
|
||||
'Adventure',
|
||||
'Crime',
|
||||
'Family',
|
||||
'War',
|
||||
'Western',
|
||||
'History',
|
||||
]
|
||||
|
||||
/**
|
||||
* "Your library is heavy on X, light on Y" finder. Computes the genre
|
||||
* distribution across the user's Movie + Series catalogue, picks the
|
||||
* underrepresented INTERESTING_GENRES (definition: < 30% of the
|
||||
* top-genre count AND fewer than 12 absolute items), and surfaces a
|
||||
* top-rated TMDB row for each one.
|
||||
*
|
||||
* Hides itself when:
|
||||
* - The user has fewer than 30 items total (results would be noise)
|
||||
* - No genre crosses the under-representation threshold
|
||||
*/
|
||||
export default function LibraryGapFinder() {
|
||||
const distQuery = useLibraryGenreDistribution()
|
||||
const lib = useLibraryByTmdbId()
|
||||
|
||||
const gaps = useMemo(() => {
|
||||
const data = distQuery.data
|
||||
if (!data || data.total < 30) return [] as Array<{ genre: string; count: number; top: number }>
|
||||
const top = Math.max(...Array.from(data.counts.values()), 1)
|
||||
return INTERESTING_GENRES
|
||||
.map(g => {
|
||||
const count = data.counts.get(g) || 0
|
||||
return { genre: g, count, top }
|
||||
})
|
||||
.filter(g => g.count < 12 && g.count < top * 0.3)
|
||||
// Most-glaring gaps first (the ones with the biggest delta from top).
|
||||
.sort((a, b) => a.count - b.count)
|
||||
.slice(0, 3)
|
||||
}, [distQuery.data])
|
||||
|
||||
if (gaps.length === 0) return null
|
||||
|
||||
const data = distQuery.data!
|
||||
const topEntry = [...data.counts.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
const topGenre = topEntry ? topEntry[0] : null
|
||||
|
||||
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]">
|
||||
Library gaps
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-[20px] font-bold tracking-tight text-text-1 leading-tight">
|
||||
What your shelves are missing
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-3 mt-0.5 max-w-2xl">
|
||||
You have plenty of <span className="text-text-2 font-medium">{topGenre || '...'}</span>{' '}
|
||||
but barely anything in {gaps.map((g, i) => (
|
||||
<span key={g.genre}>
|
||||
{i > 0 && (i === gaps.length - 1 ? ' or ' : ', ')}
|
||||
<span className="text-text-2 font-medium">{g.genre.toLowerCase()}</span>
|
||||
<span className="text-text-4 tabular-nums"> ({g.count})</span>
|
||||
</span>
|
||||
))}. A few top-rated picks worth adding:
|
||||
</p>
|
||||
</div>
|
||||
{gaps.map(g => (
|
||||
<GapRow key={g.genre} genre={g.genre} libraryMap={lib.data} />
|
||||
))}
|
||||
<CuratedGapRow
|
||||
title="IMDb Top 250"
|
||||
subtitle="Highest-rated films of all time you don't have"
|
||||
params={{
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '5000',
|
||||
'vote_average.gte': '8.0',
|
||||
page: '1',
|
||||
}}
|
||||
libraryMap={lib.data}
|
||||
/>
|
||||
<CuratedGapRow
|
||||
title="A24"
|
||||
subtitle="Essential films from the acclaimed studio"
|
||||
params={{
|
||||
with_companies: A24_COMPANY_ID,
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '100',
|
||||
page: '1',
|
||||
}}
|
||||
libraryMap={lib.data}
|
||||
/>
|
||||
<CuratedGapRow
|
||||
title="Oscar Best Picture"
|
||||
subtitle="Academy Award winners worth owning"
|
||||
params={{
|
||||
with_keywords: OSCAR_KEYWORD_ID,
|
||||
sort_by: 'vote_average.desc',
|
||||
'vote_count.gte': '500',
|
||||
'vote_average.gte': '7.0',
|
||||
page: '1',
|
||||
}}
|
||||
libraryMap={lib.data}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const A24_COMPANY_ID = '41077'
|
||||
const OSCAR_KEYWORD_ID = '10271' // Academy Awards
|
||||
|
||||
function CuratedGapRow({
|
||||
title,
|
||||
subtitle,
|
||||
params,
|
||||
libraryMap,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
params: Record<string, string>
|
||||
libraryMap?: Map<string, { id: string; name: string; type: string }>
|
||||
}) {
|
||||
const movies = useTmdbDiscoverMovies(params)
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap)
|
||||
}, [movies.data, libraryMap, hideAdult])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
items={items}
|
||||
layoutKey={`gap_${title}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GapRow({
|
||||
genre,
|
||||
libraryMap,
|
||||
}: {
|
||||
genre: string
|
||||
libraryMap?: Map<string, { id: string; name: string; type: string }>
|
||||
}) {
|
||||
const genreId = tmdbMovieGenreId(genre)
|
||||
const params = genreId
|
||||
? {
|
||||
with_genres: String(genreId),
|
||||
'vote_count.gte': '1000',
|
||||
'vote_average.gte': '7.2',
|
||||
sort_by: 'vote_average.desc',
|
||||
}
|
||||
: ({} as Record<string, string>)
|
||||
const movies = useTmdbDiscoverMovies(params)
|
||||
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||
const items = useMemo(() => {
|
||||
const raw = (movies.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
|
||||
return mapTmdbToJf(filterToMissing(raw, libraryMap, hideAdult, m => !!(m as any).adult), libraryMap)
|
||||
}, [movies.data, libraryMap, hideAdult])
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
title={`Try some ${genre.toLowerCase()}`}
|
||||
subtitle={`Top-rated ${genre.toLowerCase()} films you don't have`}
|
||||
items={items}
|
||||
layoutKey={`gap_${genre}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user