discover components

This commit is contained in:
2026-03-29 05:55:12 +03:00
parent a039249ede
commit 02d65fbeeb
10 changed files with 2161 additions and 0 deletions
@@ -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}`}
/>
)
}