diff --git a/apps/api/src/modules/tmdb/router.ts b/apps/api/src/modules/tmdb/router.ts
index 624dc08..cbe84fd 100644
--- a/apps/api/src/modules/tmdb/router.ts
+++ b/apps/api/src/modules/tmdb/router.ts
@@ -5,6 +5,7 @@ import { TmdbModel } from "./model";
import {
discoverFeed,
getMediaDetail,
+ getMediaRecommendations,
getTvEpisodeDetail,
getTvSeasonDetail,
getWatchProviders,
@@ -66,6 +67,18 @@ export const tmdbController = new Elysia({
},
},
)
+ .get(
+ "/:mediaType/:tmdbId/recommendations",
+ ({ params, query }) =>
+ getMediaRecommendations(params.mediaType, params.tmdbId, query.language ?? DEFAULT_LANGUAGE),
+ {
+ params: "tmdb.MediaParams",
+ query: "tmdb.LanguageQuery",
+ response: {
+ 200: "tmdb.SummaryList",
+ },
+ },
+ )
.get(
"/tv/:seriesId/season/:seasonNumber",
({ params, query }) =>
diff --git a/apps/api/src/modules/whats-new/data.ts b/apps/api/src/modules/whats-new/data.ts
index 4f9a096..2819929 100644
--- a/apps/api/src/modules/whats-new/data.ts
+++ b/apps/api/src/modules/whats-new/data.ts
@@ -20,6 +20,19 @@ interface WhatsNewRelease {
}
export const WHATS_NEW_RELEASES: WhatsNewRelease[] = [
+ {
+ id: "you-may-also-like",
+ features: [
+ {
+ icon: "rectangle.stack",
+ title: { en: "You May Also Like", fr: "À voir aussi" },
+ description: {
+ en: "Discover related titles right on any movie or series page.",
+ fr: "Trouvez des titres similaires directement sur chaque film ou série.",
+ },
+ },
+ ],
+ },
{
id: "letterboxd-account",
features: [
diff --git a/apps/mobile/src/components/screens/media-detail/index.tsx b/apps/mobile/src/components/screens/media-detail/index.tsx
index ba8132a..e7d0836 100644
--- a/apps/mobile/src/components/screens/media-detail/index.tsx
+++ b/apps/mobile/src/components/screens/media-detail/index.tsx
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScreenHeader, ScreenToolbar, type ScreenAction } from "@/components/navigation";
import { Text } from "@/components/ui/text";
+import { useMediaRouteBase } from "@/hooks/use-media-route-base";
import { useTheme } from "@/hooks/use-theme";
import { useWatchAction } from "@/hooks/watch-sessions/use-watch-action";
@@ -17,6 +18,7 @@ import { MediaParallaxHeader } from "./media-parallax-header";
import { MediaSummary } from "./media-summary";
import { OverviewSection } from "./overview-section";
import { RatingsSection } from "./ratings-section";
+import { RecommendationsSection } from "./recommendations-section";
import { useMediaDetailViewModel } from "./use-media-detail-view-model";
import { WatchProvidersSection } from "./watch-providers-section";
@@ -24,6 +26,7 @@ export function MediaDetail() {
const theme = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
+ const base = useMediaRouteBase();
const vm = useMediaDetailViewModel();
const handleRate = useCallback(() => vm.openReview(vm.myStars || undefined), [vm]);
@@ -148,6 +151,8 @@ export function MediaDetail() {
onOpenReviews={vm.openReviews}
/>
+
+
{vm.isLoading && !vm.detail ? (
Loading…
diff --git a/apps/mobile/src/components/screens/media-detail/recommendations-section.tsx b/apps/mobile/src/components/screens/media-detail/recommendations-section.tsx
new file mode 100644
index 0000000..5b80969
--- /dev/null
+++ b/apps/mobile/src/components/screens/media-detail/recommendations-section.tsx
@@ -0,0 +1,50 @@
+import { useTranslation } from "react-i18next";
+import { StyleSheet, View } from "react-native";
+
+import { PosterCard } from "@/components/discover/poster-card";
+import { Shelf } from "@/components/discover/shelf";
+import { SPACING } from "@/constants/design-tokens";
+import { useMediaRecommendations } from "@/hooks/tmdb/use-media-recommendations";
+import type { MediaType } from "@/lib/tmdb";
+import type { MediaRouteBase } from "@/lib/navigation";
+
+import { DetailSection } from "./detail-section";
+
+export function RecommendationsSection({
+ tmdbId,
+ mediaType,
+ base,
+}: {
+ tmdbId: number;
+ mediaType: MediaType;
+ base: MediaRouteBase;
+}) {
+ const { t } = useTranslation();
+ const { media } = useMediaRecommendations(tmdbId, mediaType);
+
+ if (media.length === 0) return null;
+
+ return (
+
+
+ `${item.media_type}-${item.id}`}
+ renderItem={(item, _index, cardWidth) => (
+
+ )}
+ visibleCards={3}
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ // The parallax body pads horizontally by SPACING.MD; break out so the shelf
+ // spans edge-to-edge like the cast row, letting Shelf own its own padding.
+ shelf: {
+ marginHorizontal: -SPACING.MD,
+ },
+});
diff --git a/apps/mobile/src/hooks/tmdb/use-media-recommendations.ts b/apps/mobile/src/hooks/tmdb/use-media-recommendations.ts
new file mode 100644
index 0000000..938efad
--- /dev/null
+++ b/apps/mobile/src/hooks/tmdb/use-media-recommendations.ts
@@ -0,0 +1,33 @@
+import { useQuery } from "@tanstack/react-query";
+import { tmdbKeys } from "@seen/shared";
+import { useTranslation } from "react-i18next";
+
+import {
+ getMediaRecommendations,
+ type MediaType,
+ type TmdbMovieSummary,
+ tmdbLanguage,
+} from "@/lib/tmdb";
+
+interface MediaRecommendationsState {
+ media: TmdbMovieSummary[];
+ isLoading: boolean;
+}
+
+export function useMediaRecommendations(
+ tmdbId: number,
+ mediaType: MediaType,
+): MediaRecommendationsState {
+ const { i18n } = useTranslation();
+ const language = tmdbLanguage(i18n.language);
+ const query = useQuery({
+ queryKey: tmdbKeys.recommendations(mediaType, tmdbId, language),
+ queryFn: () => getMediaRecommendations(tmdbId, mediaType, language),
+ enabled: Number.isFinite(tmdbId) && tmdbId > 0,
+ });
+
+ return {
+ media: query.data ?? [],
+ isLoading: query.isLoading,
+ };
+}
diff --git a/apps/mobile/src/lib/i18n/locales/en.ts b/apps/mobile/src/lib/i18n/locales/en.ts
index a15d6eb..43137e5 100644
--- a/apps/mobile/src/lib/i18n/locales/en.ts
+++ b/apps/mobile/src/lib/i18n/locales/en.ts
@@ -170,6 +170,7 @@ export const en = {
noReviewsHint: "Be the first to share what you thought.",
retry: "Retry",
whereToWatch: "Where to watch",
+ youMayAlsoLike: "You may also like",
},
likes: {
like: "Like",
diff --git a/apps/mobile/src/lib/i18n/locales/fr.ts b/apps/mobile/src/lib/i18n/locales/fr.ts
index 0b6965c..30a4e35 100644
--- a/apps/mobile/src/lib/i18n/locales/fr.ts
+++ b/apps/mobile/src/lib/i18n/locales/fr.ts
@@ -174,6 +174,7 @@ export const fr: typeof en = {
noReviewsHint: "Sois le premier à partager ton avis.",
retry: "Réessayer",
whereToWatch: "Où regarder",
+ youMayAlsoLike: "À voir aussi",
},
likes: {
like: "J'aime",
diff --git a/apps/mobile/src/lib/tmdb/index.ts b/apps/mobile/src/lib/tmdb/index.ts
index 4d1706c..f0043a4 100644
--- a/apps/mobile/src/lib/tmdb/index.ts
+++ b/apps/mobile/src/lib/tmdb/index.ts
@@ -21,6 +21,7 @@ export { getDiscoverFeed } from "./discover";
export { trendingMedia } from "./trending";
export { findByExternalId } from "./find";
export { getMovieDetail } from "./movie";
+export { getMediaRecommendations } from "./recommendations";
export { getTvEpisodeDetail, getTvSeasonDetail } from "./tv";
export { getWatchProviders } from "./watch-providers";
export { tmdbLanguage } from "./client";
diff --git a/apps/mobile/src/lib/tmdb/recommendations.ts b/apps/mobile/src/lib/tmdb/recommendations.ts
new file mode 100644
index 0000000..3a4f190
--- /dev/null
+++ b/apps/mobile/src/lib/tmdb/recommendations.ts
@@ -0,0 +1,16 @@
+import { eden, unwrapEden } from "@/lib/eden";
+
+import { tmdbLanguage } from "./client";
+import type { MediaType, TmdbMovieSummary } from "./types";
+
+export async function getMediaRecommendations(
+ tmdbId: number,
+ mediaType: MediaType,
+ language = tmdbLanguage(),
+): Promise {
+ return unwrapEden(
+ eden.tmdb({ mediaType })({ tmdbId }).recommendations.get({
+ query: { language },
+ }),
+ );
+}
diff --git a/packages/shared/src/query-keys.ts b/packages/shared/src/query-keys.ts
index 762758d..2a4dc0b 100644
--- a/packages/shared/src/query-keys.ts
+++ b/packages/shared/src/query-keys.ts
@@ -22,6 +22,9 @@ export const discoverKeys = {
export const tmdbKeys = {
detail: (mediaType: MediaType, tmdbId: number, locale: string) =>
["tmdb", "detail", mediaType, tmdbId, locale] as const,
+ recommendations: (mediaType: MediaType, tmdbId: number, locale: string) =>
+ ["tmdb", "recommendations", mediaType, tmdbId, locale] as const,
+ person: (personId: number, locale: string) => ["tmdb", "person", personId, locale] as const,
};
export const watchProviderKeys = {