199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useLibraryGenreDistribution, useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
|
import { useTmdbDiscoverMovies } 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}`}
|
|
/>
|
|
)
|
|
}
|