fix: reduce duplicate home recommendations

This commit is contained in:
nianzhibai
2026-06-01 19:02:41 +08:00
parent e36a17f99d
commit b6be7d021c
6 changed files with 200 additions and 23 deletions
+39 -20
View File
@@ -156,41 +156,60 @@ func (s *Server) handleGetTheme(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
// 首页优先展示封面已经生成好的视频,避免新盘扫盘时大量黑封面占满首页
// 候选仍按发布时间覆盖最近 200 个,随后随机洗牌;封面不足时再用普通可见视频补齐。
const candidatePool = 200
readyItems, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "latest", Page: 1, PageSize: candidatePool, ThumbnailReadyOnly: true,
})
// 首页优先从全量已有封面的视频里随机抽取,避免只在最近一小段候选中反复出现
excludeIDs := parseVideoIDQuery(r, "exclude", 120)
items, err := s.Catalog.RandomVideosWithReadyThumbnailsExcluding(r.Context(), excludeIDs, homePageSize)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
rand.Shuffle(len(readyItems), func(i, j int) {
readyItems[i], readyItems[j] = readyItems[j], readyItems[i]
})
items := appendUniqueVideos(nil, readyItems, homePageSize)
if len(items) > homePageSize {
items = items[:homePageSize]
}
if len(items) < homePageSize {
fallback, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "latest", Page: 1, PageSize: candidatePool,
})
fallbackExclude := append([]string{}, excludeIDs...)
for _, item := range items {
if item != nil {
fallbackExclude = append(fallbackExclude, item.ID)
}
}
fallback, err := s.Catalog.RandomVideosExcluding(r.Context(), fallbackExclude, homePageSize-len(items))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
rand.Shuffle(len(fallback), func(i, j int) {
fallback[i], fallback[j] = fallback[j], fallback[i]
})
items = appendUniqueVideos(items, fallback, homePageSize)
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, mapVideos(items))
}
func parseVideoIDQuery(r *http.Request, key string, limit int) []string {
if r == nil {
return nil
}
values := r.URL.Query()[key]
if len(values) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
for _, id := range strings.Split(value, ",") {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
if limit > 0 && len(out) >= limit {
return out
}
}
}
return out
}
func appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit int) []*catalog.Video {
if len(dst) >= limit {
return dst[:limit]
+53
View File
@@ -166,6 +166,59 @@ func TestHandleHomePrioritizesVideosWithReadyThumbnails(t *testing.T) {
}
}
func TestHandleHomeExcludesRecentlyShownVideos(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
for i := 0; i < homePageSize+4; i++ {
id := "ready-video-" + strconv.Itoa(i)
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: id,
DriveID: "drive",
FileID: id,
Title: id,
ThumbnailURL: "https://thumb.example/" + id + ".jpg",
PublishedAt: now.Add(time.Duration(i) * time.Minute),
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("seed ready video %s: %v", id, err)
}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/home?exclude=ready-video-0&exclude=ready-video-1", nil)
(&Server{Catalog: cat}).handleHome(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got []VideoDTO
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(got) != homePageSize {
t.Fatalf("home items = %d, want %d", len(got), homePageSize)
}
for _, item := range got {
if item.ID == "ready-video-0" || item.ID == "ready-video-1" {
t.Fatalf("home returned excluded video %q; items=%#v", item.ID, got)
}
if !strings.HasPrefix(item.ID, "ready-video-") {
t.Fatalf("home returned %q without a ready thumbnail; items=%#v", item.ID, got)
}
}
}
func TestHandleListLatestPrefersReadyThumbnails(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+11
View File
@@ -932,6 +932,14 @@ func (c *Catalog) CountVisibleVideos(ctx context.Context) (int, error) {
// 如果剩余可选数量 < limit,就返回所有可选项;调用方负责判断是否需要开新一轮。
// limit <= 0 时返回 nil, nil。
func (c *Catalog) RandomVideosExcluding(ctx context.Context, excludeIDs []string, limit int) ([]*Video, error) {
return c.randomVideosExcluding(ctx, excludeIDs, limit, false)
}
func (c *Catalog) RandomVideosWithReadyThumbnailsExcluding(ctx context.Context, excludeIDs []string, limit int) ([]*Video, error) {
return c.randomVideosExcluding(ctx, excludeIDs, limit, true)
}
func (c *Catalog) randomVideosExcluding(ctx context.Context, excludeIDs []string, limit int, thumbnailReadyOnly bool) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
@@ -940,6 +948,9 @@ func (c *Catalog) RandomVideosExcluding(ctx context.Context, excludeIDs []string
args := make([]any, 0, len(cleaned)+1)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + uniqueVideoWhereSQL
if thumbnailReadyOnly {
whereSQL += " AND COALESCE(thumbnail_url, '') != ''"
}
if len(cleaned) > 0 {
placeholders := strings.Repeat("?,", len(cleaned))
placeholders = placeholders[:len(placeholders)-1]
+56
View File
@@ -110,6 +110,62 @@ func TestRandomVideosExcluding(t *testing.T) {
}
}
func TestRandomVideosWithReadyThumbnailsExcluding(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
for i := 0; i < 4; i++ {
id := "ready-" + string(rune('a'+i))
if err := cat.UpsertVideo(ctx, &Video{
ID: id,
DriveID: "drive",
FileID: "f-" + id,
Title: id,
ThumbnailURL: "/p/thumb/" + id,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed %s: %v", id, err)
}
}
for i := 0; i < 4; i++ {
id := "pending-" + string(rune('a'+i))
if err := cat.UpsertVideo(ctx, &Video{
ID: id,
DriveID: "drive",
FileID: "f-" + id,
Title: id,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed %s: %v", id, err)
}
}
got, err := cat.RandomVideosWithReadyThumbnailsExcluding(ctx, []string{"ready-a"}, 10)
if err != nil {
t.Fatalf("random ready excluding: %v", err)
}
if len(got) != 3 {
t.Fatalf("ready random count = %d, want 3", len(got))
}
for _, v := range got {
if v.ID == "ready-a" {
t.Fatal("excluded ready video was returned")
}
if v.ThumbnailURL == "" {
t.Fatalf("pending video %q was returned", v.ID)
}
}
}
func TestRandomVideosForPreferredVideoChoosesLeastPopulatedTag(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
+7 -2
View File
@@ -1,8 +1,13 @@
import type { VideoDetail, VideoItem } from "@/types";
// 真实后端接口调用。未配置网盘时,各接口返回空数据。
export function fetchHomeVideos(): Promise<VideoItem[]> {
return apiGet<VideoItem[]>("/api/home").catch(() => []);
export function fetchHomeVideos(excludeIds?: string[]): Promise<VideoItem[]> {
const qs = new URLSearchParams();
for (const id of excludeIds ?? []) {
if (id.trim()) qs.append("exclude", id.trim());
}
const suffix = qs.toString() ? `?${qs.toString()}` : "";
return apiGet<VideoItem[]>(`/api/home${suffix}`).catch(() => []);
}
export function fetchListing(
+34 -1
View File
@@ -10,6 +10,8 @@ import type { VideoItem } from "@/types";
const DESKTOP_COUNT = 12;
const MOBILE_COUNT = 8;
const HOME_RECENT_KEY = "home.random.recentVideoIds";
const HOME_RECENT_LIMIT = 72;
function useIsMobile() {
const [mobile, setMobile] = useState(window.innerWidth <= 640);
@@ -26,6 +28,35 @@ function useIsMobile() {
let cachedRanking: VideoItem[] | null = null;
let cachedLatest: VideoItem[] | null = null;
function loadRecentHomeVideoIds(): string[] {
try {
const raw = window.localStorage.getItem(HOME_RECENT_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return [];
}
}
function rememberHomeVideos(items: VideoItem[]) {
const merged = [...items.map((item) => item.id), ...loadRecentHomeVideoIds()];
const seen = new Set<string>();
const recent: string[] = [];
for (const id of merged) {
if (!id || seen.has(id)) continue;
seen.add(id);
recent.push(id);
if (recent.length >= HOME_RECENT_LIMIT) break;
}
try {
window.localStorage.setItem(HOME_RECENT_KEY, JSON.stringify(recent));
} catch {
// localStorage 不可用时只影响连续刷新去重,不影响首页展示。
}
}
export default function HomePage() {
const [rankingVideos, setRankingVideos] = useState<VideoItem[]>(cachedRanking ?? []);
const [latestVideos, setLatestVideos] = useState<VideoItem[]>(cachedLatest ?? []);
@@ -40,11 +71,13 @@ export default function HomePage() {
let active = true;
setLoading(true);
const excludeIds = loadRecentHomeVideoIds();
Promise.all([
fetchHomeVideos(),
fetchHomeVideos(excludeIds),
fetchListing(1, DESKTOP_COUNT, { sort: "latest" }),
]).then(([rankingItems, latestResult]) => {
if (!active) return;
rememberHomeVideos(rankingItems);
cachedRanking = rankingItems;
cachedLatest = latestResult.items;
setRankingVideos(rankingItems);