)
}
/* ────────────────────────────────────────────────────────────── */
/* Recommendation rows */
/* ────────────────────────────────────────────────────────────── */
/**
* "More like this" - pulls TMDB recommendations on the just-watched
* item. For episodes, sources from the parent series since per-episode
* recommendations don't exist.
*/
function MoreLikeThis({ item }: { item: BaseItemDto }) {
// Episodes: fall back to series TMDB id when present (passed via item.SeriesId
// would require an extra fetch; the player already has it in the parent's
// ProviderIds when we navigate from a series detail page, but for safety
// we use the item's own ProviderIds or null).
const tmdbId = item.ProviderIds?.Tmdb ? Number(item.ProviderIds.Tmdb) : null
const isSeries = item.Type === 'Series' || item.Type === 'Episode'
const movieFull = useTmdbMovie(!isSeries ? tmdbId : null)
const tvFull = useTmdbTvShow(isSeries ? tmdbId : null)
const libraryByTmdbId = useLibraryByTmdbId()
const items = useMemo(() => {
const recs = isSeries ? tvFull.data?.recommendations?.results : movieFull.data?.recommendations?.results
if (!recs) return []
return mapTmdbToJf(recs.slice(0, 12), libraryByTmdbId.data)
}, [movieFull.data, tvFull.data, isSeries, libraryByTmdbId.data])
if (!tmdbId || items.length === 0) return null
return
}
const VIBE_FLIP: Record = {
Horror: 35, // → Comedy
Thriller: 35,
Crime: 35,
Drama: 12, // → Adventure
Documentary: 28, // → Action
Action: 18, // → Drama
Adventure: 18,
Comedy: 27, // → Horror
Family: 53, // → Thriller
Animation: 80, // → Crime
Romance: 878, // → Sci-Fi
'Science Fiction': 10749, // → Romance
}
/**
* "Different vibe" - top-rated picks from the genre roughly opposite
* the just-watched item. Useful when the user wants a palate cleanser
* after a heavy watch.
*/
function DifferentVibe({ item }: { item: BaseItemDto }) {
const oppositeGenreId = useMemo(() => {
const genres = item.Genres || []
for (const g of genres) {
if (VIBE_FLIP[g] != null) return VIBE_FLIP[g]
}
// Fall back: if the source genre is in our movie-genre map, swap to
// the most distant entry (Documentary) so we always emit something.
return tmdbMovieGenreId('Documentary')
}, [item.Genres])
const discover = useTmdbDiscoverMovies(
oppositeGenreId
? {
with_genres: String(oppositeGenreId),
'vote_count.gte': '500',
sort_by: 'vote_average.desc',
}
: ({} as Record),
)
const libraryByTmdbId = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = (discover.data?.results || []).slice(0, 10).map(m => ({ ...m, media_type: 'movie' }))
return mapTmdbToJf(raw, libraryByTmdbId.data)
}, [discover.data, libraryByTmdbId.data])
if (items.length === 0) return null
return
}
function RowStrip({ title, items }: { title: string; items: BaseItemDto[] }) {
const navigate = useNavigate()
return (
{title}
{items.map(it => {
const tmdbPoster = it._tmdbPoster
const inLibrary = it._inLibrary === true
const poster =
tmdbPoster ||
(it.ImageTags?.Primary && it.Id
? null // Local items go through real Jellyfin URL on click; we skip the thumb here
: null)
return (
)
})}