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 (
Library gaps

What your shelves are missing

You have plenty of {topGenre || '...'}{' '} but barely anything in {gaps.map((g, i) => ( {i > 0 && (i === gaps.length - 1 ? ' or ' : ', ')} {g.genre.toLowerCase()} ({g.count}) ))}. A few top-rated picks worth adding:

{gaps.map(g => ( ))}
) } const A24_COMPANY_ID = '41077' const OSCAR_KEYWORD_ID = '10271' // Academy Awards function CuratedGapRow({ title, subtitle, params, libraryMap, }: { title: string subtitle: string params: Record libraryMap?: Map }) { 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 ( ) } function GapRow({ genre, libraryMap, }: { genre: string libraryMap?: Map }) { 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) 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 ( ) }