Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 99 additions & 41 deletions src/pages/HomePage.jsx
Comment thread
truelockmc marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -79,32 +92,78 @@ export default function HomePage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ratingsMap, ageLimitSetting]);

// Fetch similar items based on recent watch history
// 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;
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]);

Expand Down Expand Up @@ -306,20 +365,19 @@ export default function HomePage({
};

if (id === "similar") {
if (!similarSource || similarItems.length === 0) return null;
if (filteredRecommendedItems.length === 0) return null;
if (viewMode === "list")
return renderList(
"similar",
"Similar to",
similarSource.title || similarSource.name,
similarItems,
"Recommended for You",
null,
filteredRecommendedItems,
);
return (
<TrendingCarousel
key="similar"
items={similarItems}
title="Similar to"
titleHighlight={similarSource.title || similarSource.name}
items={filteredRecommendedItems}
title="Recommended for You"
onSelect={onSelect}
ratingsMap={enrichedRatingsMap}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/utils/homeLayout.js

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also change the ID to recommended please

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!, have also resolved them

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fast response, but please remove the migration logic again.

The row will be shown by default and i think it is good if users instantly see this new feature after updating and it does not perish because user's didn't like the Similar to... feature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, have resolved those as well

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { storage } from "./storage";

export const HOME_ROWS = [
{ id: "continue", label: "Continue Watching" },
{ id: "similar", label: "Similar to…" },
{ id: "similar", label: "Recommended for You" },
{ id: "trendingMovies", label: "Trending Movies" },
{ id: "trendingTV", label: "Trending Series" },
{ id: "topRated", label: "Top Rated" },
Expand Down
Loading