From 54687dcc330cd358389021a10b39a1768b49748c Mon Sep 17 00:00:00 2001 From: rogueburger21 Date: Sat, 16 May 2026 23:39:22 +0530 Subject: [PATCH 1/4] feat(home): personal recommendations based on watch history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced the single-source "Similar to {Title}" carousel with a multi-source "Recommended for You" section that aggregates TMDB recommendations from up to 5 recently watched items. - Uses /recommendations endpoint first, falls back to /similar when no results are returned for a given source item. - Interleaves results from each source for maximum variety (e.g., rec-1 from Movie A, rec-1 from Series B, rec-2 from Movie A, …). - Deduplicates items across sources and filters out anything the user has already watched, capping the final list at 20 items. - Expanded the history lookback window from 7 to 30 days to ensure enough seed items for meaningful recommendations. --- src/pages/HomePage.jsx | 135 ++++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 41 deletions(-) diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 739c186..dc79d63 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -8,14 +8,28 @@ import { isRestricted } from "../utils/ageRating"; import { storage } from "../utils/storage"; import { loadHomeLayout, loadHomeViewMode } from "../utils/homeLayout"; -function getRecentHistoryItem(history) { - if (!history || history.length === 0) return null; - const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const recent = history.filter( - (h) => h.watchedAt && h.watchedAt > sevenDaysAgo, - ); - if (recent.length === 0) return null; - return recent[Math.floor(Math.random() * recent.length)]; +/** + * Extract up to `count` unique, recently watched items from the user's + * history (within the last 30 days). Returns newest-first and dedupes + * by TMDB id + media_type so we don't fire duplicate API calls. + */ +function getRecentHistoryItems(history, count = 5) { + if (!history || history.length === 0) return []; + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + const recent = history + .filter((h) => h.watchedAt && h.watchedAt > thirtyDaysAgo) + .sort((a, b) => b.watchedAt - a.watchedAt); + + const seen = new Set(); + const unique = []; + for (const item of recent) { + const key = `${item.media_type || "movie"}_${item.id}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(item); + if (unique.length >= count) break; + } + return unique; } export default function HomePage({ @@ -35,8 +49,7 @@ export default function HomePage({ }) { const hero = trending[0]; - const [similarItems, setSimilarItems] = useState([]); - const [similarSource, setSimilarSource] = useState(null); + const [recommendedItems, setRecommendedItems] = useState([]); const [topRatedItems, setTopRatedItems] = useState([]); // Load layout config (order + visibility) once on mount @@ -51,10 +64,10 @@ export default function HomePage({ ...inProgress, ...trending.map((i) => ({ ...i, media_type: "movie" })), ...trendingTV.map((i) => ({ ...i, media_type: "tv" })), - ...similarItems, + ...recommendedItems, ...topRatedItems, ], - [inProgress, trending, trendingTV, similarItems, topRatedItems], + [inProgress, trending, trendingTV, recommendedItems, topRatedItems], ); const { ratingsMap, ageLimitSetting } = useRatings(allItems); @@ -79,32 +92,73 @@ export default function HomePage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ratingsMap, ageLimitSetting]); - // Fetch similar items based on recent watch history + // Fetch personalised recommendations from multiple recent history items useEffect(() => { if (!apiKey || offline || !history || history.length === 0) return; - const source = getRecentHistoryItem(history); - if (!source) return; - setSimilarSource(source); - const type = source.media_type === "tv" ? "tv" : "movie"; - const tryFetch = (endpoint) => - tmdbFetch(`/${type}/${source.id}/${endpoint}`, apiKey).then((data) => - (data.results || []) - .slice(0, 10) - .map((item) => ({ ...item, media_type: type })), - ); - tryFetch("similar") - .then((results) => { - if (results.length > 0) { - setSimilarItems(results); - return; + const sources = getRecentHistoryItems(history, 5); + if (sources.length === 0) return; + + const controller = new AbortController(); + + // Build a Set of already-watched TMDB ids for dedup + const watchedIds = new Set( + (history || []).map((h) => `${h.media_type || "movie"}_${h.id}`), + ); + + // For each source, try /recommendations first, fall back to /similar + const fetches = sources.map((source) => { + const type = source.media_type === "tv" ? "tv" : "movie"; + return tmdbFetch( + `/${type}/${source.id}/recommendations`, + apiKey, + { signal: controller.signal }, + ) + .then((data) => { + const results = (data.results || []).map((i) => ({ + ...i, + media_type: type, + })); + if (results.length > 0) return results; + // Fall back to /similar if /recommendations returned nothing + return tmdbFetch( + `/${type}/${source.id}/similar`, + apiKey, + { signal: controller.signal }, + ).then((d) => + (d.results || []).map((i) => ({ ...i, media_type: type })), + ); + }) + .catch(() => []); + }); + + Promise.all(fetches) + .then((arrays) => { + // Interleave results from each source for variety + const merged = []; + const maxLen = Math.max(...arrays.map((a) => a.length)); + for (let i = 0; i < maxLen; i++) { + for (const arr of arrays) { + if (arr[i]) merged.push(arr[i]); + } } - return tryFetch("recommendations").then(setSimilarItems); + + // Deduplicate and filter out already-watched items + const seen = new Set(); + const deduped = merged.filter((item) => { + const key = `${item.media_type}_${item.id}`; + if (seen.has(key) || watchedIds.has(key)) return false; + seen.add(key); + return true; + }); + + setRecommendedItems(deduped.slice(0, 20)); }) - .catch(() => - tryFetch("recommendations") - .then(setSimilarItems) - .catch(() => {}), - ); + .catch((e) => { + if (e.name !== "AbortError") + console.warn("Recommendations fetch failed", e); + }); + + return () => controller.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKey, offline, history?.length]); @@ -306,20 +360,19 @@ export default function HomePage({ }; if (id === "similar") { - if (!similarSource || similarItems.length === 0) return null; + if (recommendedItems.length === 0) return null; if (viewMode === "list") return renderList( "similar", - "Similar to", - similarSource.title || similarSource.name, - similarItems, + "Recommended for You", + null, + recommendedItems, ); return ( From 3c59e942c50d935a8888556cd59c40108c2345fb Mon Sep 17 00:00:00 2001 From: rogueburger21 Date: Wed, 20 May 2026 22:16:41 +0530 Subject: [PATCH 2/4] fix(home): rename layout row in settings and filter recommendations by age rating --- src/pages/HomePage.jsx | 11 ++++++++--- src/utils/homeLayout.js | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index dc79d63..cf3b282 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -92,6 +92,11 @@ export default function HomePage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ratingsMap, ageLimitSetting]); + // Filter recommended items that exceed age limit setting + const filteredRecommendedItems = useMemo(() => { + return recommendedItems.filter((item) => !itemRestricted(item)); + }, [recommendedItems, itemRestricted]); + // Fetch personalised recommendations from multiple recent history items useEffect(() => { if (!apiKey || offline || !history || history.length === 0) return; @@ -360,18 +365,18 @@ export default function HomePage({ }; if (id === "similar") { - if (recommendedItems.length === 0) return null; + if (filteredRecommendedItems.length === 0) return null; if (viewMode === "list") return renderList( "similar", "Recommended for You", null, - recommendedItems, + filteredRecommendedItems, ); return ( Date: Wed, 20 May 2026 22:23:31 +0530 Subject: [PATCH 3/4] fix(home): change layout row ID from similar to recommended and add settings migration --- src/pages/HomePage.jsx | 6 +++--- src/utils/homeLayout.js | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index cf3b282..65fff19 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -364,18 +364,18 @@ export default function HomePage({ ); }; - if (id === "similar") { + if (id === "recommended") { if (filteredRecommendedItems.length === 0) return null; if (viewMode === "list") return renderList( - "similar", + "recommended", "Recommended for You", null, filteredRecommendedItems, ); return ( (id === "similar" ? "recommended" : id)); + storage.set("homeRowOrder", savedOrder); + } + if (savedVisible && "similar" in savedVisible) { + savedVisible.recommended = savedVisible.similar; + delete savedVisible.similar; + storage.set("homeRowVisible", savedVisible); + } + const knownIds = new Set(HOME_ROWS.map((r) => r.id)); const order = savedOrder From 0e7ee6cc0126a820a9bebe59353fb3e1e9fda04a Mon Sep 17 00:00:00 2001 From: rogueburger21 Date: Wed, 20 May 2026 22:56:22 +0530 Subject: [PATCH 4/4] revert(home): remove settings layout migration logic --- src/utils/homeLayout.js | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/utils/homeLayout.js b/src/utils/homeLayout.js index a3e8399..c6baa17 100644 --- a/src/utils/homeLayout.js +++ b/src/utils/homeLayout.js @@ -17,20 +17,8 @@ const DEFAULT_ROW_VISIBLE = Object.fromEntries( ); export function loadHomeLayout() { - let savedOrder = storage.get("homeRowOrder"); - let savedVisible = storage.get("homeRowVisible"); - - // Migration: rename 'similar' to 'recommended' - if (savedOrder && savedOrder.includes("similar")) { - savedOrder = savedOrder.map((id) => (id === "similar" ? "recommended" : id)); - storage.set("homeRowOrder", savedOrder); - } - if (savedVisible && "similar" in savedVisible) { - savedVisible.recommended = savedVisible.similar; - delete savedVisible.similar; - storage.set("homeRowVisible", savedVisible); - } - + const savedOrder = storage.get("homeRowOrder"); + const savedVisible = storage.get("homeRowVisible"); const knownIds = new Set(HOME_ROWS.map((r) => r.id)); const order = savedOrder