feat: Implement query regeneration for personalized recommendations to fetch more content and refine tab pre-selection logic.

This commit is contained in:
kuekhaoyang
2026-02-17 12:12:21 +08:00
parent 338981dadb
commit 2e7aebc513
5 changed files with 72 additions and 15 deletions
+10 -3
View File
@@ -6,7 +6,7 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { TagManager } from './TagManager';
import { MovieGrid } from './MovieGrid';
import { useTagManager } from './hooks/useTagManager';
@@ -49,8 +49,15 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
// Track whether the recommendation tab is active
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
// Sync default selection when hasHistory changes
// (on first render, if hasHistory is true, recommendation tab is pre-selected)
// Sync selection when hasHistory changes after Zustand hydration from localStorage.
// On first render the store is empty (hasHistory=false), so useState captures false.
// Once hydration completes and hasHistory becomes true, auto-select the recommendation tab.
useEffect(() => {
if (hasHistory) {
setIsRecommendSelected(true);
}
}, [hasHistory]);
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
const {
@@ -10,6 +10,7 @@
* - Randomizes Douban API offsets so each page load shows different content
* - Excludes already-watched titles
* - Auto-infinite-scroll via useInfiniteScroll (no "load more" button)
* - When current queries exhaust, regenerates with new random offsets for more content
* - Caches results for 30 minutes
* - hasHistory = true when viewingHistory.length >= 2
*/
@@ -38,6 +39,7 @@ interface InterleavedMovie extends DoubanMovie {
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
const ITEMS_PER_PAGE = 18; // How many to fetch per query per page
const MAX_ROUNDS = 8; // Max times to regenerate queries before giving up
export function usePersonalizedRecommendations(isPremium = false) {
const normalHistory = useHistoryStore();
@@ -49,6 +51,8 @@ export function usePersonalizedRecommendations(isPremium = false) {
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const queriesRef = useRef<RecommendationQuery[]>([]);
const roundRef = useRef(0); // Track how many times we've regenerated queries
const allSeenTitlesRef = useRef<Set<string>>(new Set()); // Global dedup across rounds
const cacheRef = useRef<{
key: string;
movies: InterleavedMovie[];
@@ -99,6 +103,8 @@ export function usePersonalizedRecommendations(isPremium = false) {
const queries = generateRecommendations(viewingHistory);
queriesRef.current = queries;
roundRef.current = 0;
allSeenTitlesRef.current = new Set();
if (queries.length === 0) {
setMovies([]);
@@ -115,6 +121,10 @@ export function usePersonalizedRecommendations(isPremium = false) {
Date.now() - cacheRef.current.timestamp < CACHE_DURATION
) {
setMovies(cacheRef.current.movies);
// Rebuild seen titles from cache
for (const m of cacheRef.current.movies) {
allSeenTitlesRef.current.add(m.title.toLowerCase().trim());
}
setHasMore(true);
return;
}
@@ -129,9 +139,13 @@ export function usePersonalizedRecommendations(isPremium = false) {
fetchPage(queries, 0, watchedTitles).then((interleaved) => {
if (cancelled) return;
setMovies(interleaved);
setHasMore(interleaved.length >= queries.length * 2);
for (const m of interleaved) {
allSeenTitlesRef.current.add(m.title.toLowerCase().trim());
}
// Always assume there's more — we can regenerate queries if this round exhausts
setHasMore(interleaved.length > 0);
cacheRef.current = {
key: cacheKey,
key: queries.map(q => `${q.tag}:${q.type}`).join('|'),
movies: interleaved,
timestamp: Date.now(),
};
@@ -154,18 +168,47 @@ export function usePersonalizedRecommendations(isPremium = false) {
try {
const newMovies = await fetchPage(queries, nextPage, watchedTitles);
// Deduplicate against existing movies
const existingTitles = new Set(movies.map(m => m.title.toLowerCase().trim()));
// Deduplicate against all previously seen movies (across all rounds)
const uniqueNew = newMovies.filter(
m => !existingTitles.has(m.title.toLowerCase().trim())
m => !allSeenTitlesRef.current.has(m.title.toLowerCase().trim())
);
if (uniqueNew.length === 0) {
setHasMore(false);
// Current queries exhausted — regenerate with new random offsets
if (roundRef.current < MAX_ROUNDS) {
roundRef.current += 1;
const freshQueries = generateRecommendations(viewingHistory);
queriesRef.current = freshQueries;
// Fetch page 0 with fresh random offsets
const freshMovies = await fetchPage(freshQueries, 0, watchedTitles);
const freshUnique = freshMovies.filter(
m => !allSeenTitlesRef.current.has(m.title.toLowerCase().trim())
);
if (freshUnique.length > 0) {
for (const m of freshUnique) {
allSeenTitlesRef.current.add(m.title.toLowerCase().trim());
}
setMovies((prev) => [...prev, ...freshUnique]);
setPage(0); // Reset page for fresh queries
if (cacheRef.current) {
cacheRef.current.movies = [...cacheRef.current.movies, ...freshUnique];
}
} else {
// Even fresh queries yielded nothing new — stop
setHasMore(false);
}
} else {
// Exceeded max rounds — stop
setHasMore(false);
}
} else {
for (const m of uniqueNew) {
allSeenTitlesRef.current.add(m.title.toLowerCase().trim());
}
setMovies((prev) => [...prev, ...uniqueNew]);
setPage(nextPage);
// Update cache
if (cacheRef.current) {
cacheRef.current.movies = [...cacheRef.current.movies, ...uniqueNew];
}
@@ -175,7 +218,7 @@ export function usePersonalizedRecommendations(isPremium = false) {
} finally {
setLoading(false);
}
}, [loading, viewingHistory, movies, fetchPage]);
}, [loading, viewingHistory, fetchPage]);
const { prefetchRef, loadMoreRef } = useInfiniteScroll({
hasMore,
+8 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { TagManager } from '@/components/home/TagManager';
import { MovieGrid } from '@/components/home/MovieGrid';
import { PremiumContentGrid } from './PremiumContentGrid';
@@ -40,6 +40,13 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
// Track whether the recommendation tab is active
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
useEffect(() => {
if (hasHistory) {
setIsRecommendSelected(true);
}
}, [hasHistory]);
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
// Get the category value from selected tag
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.3.2",
"version": "4.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.3.2",
"version": "4.3.3",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.3.2",
"version": "4.3.3",
"private": true,
"scripts": {
"dev": "next dev",