Prioritize ready thumbnails on home

This commit is contained in:
nianzhibai
2026-05-29 16:23:13 +08:00
parent d16e3168f9
commit 7e394e2971
6 changed files with 1012 additions and 1548 deletions
+47 -6
View File
@@ -155,26 +155,67 @@ func (s *Server) handleGetTheme(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
// 拉一批候选(按发布时间倒序,覆盖最近 200 个),然后随机洗牌取前 homePageSize 个
// 如果库内不足 200 个会自动按实际数量返回,最后裁剪到 homePageSize
// 首页优先展示封面已经生成好的视频,避免新盘扫盘时大量黑封面占满首页
// 候选仍按发布时间覆盖最近 200 个,随后随机洗牌;封面不足时再用普通可见视频补齐
const candidatePool = 200
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "latest", Page: 1, PageSize: candidatePool,
readyItems, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "latest", Page: 1, PageSize: candidatePool, ThumbnailReadyOnly: true,
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
rand.Shuffle(len(items), func(i, j int) {
items[i], items[j] = items[j], items[i]
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,
})
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 appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit int) []*catalog.Video {
if len(dst) >= limit {
return dst[:limit]
}
seen := make(map[string]struct{}, len(dst))
for _, v := range dst {
if v != nil {
seen[v.ID] = struct{}{}
}
}
for _, v := range candidates {
if v == nil {
continue
}
if _, ok := seen[v.ID]; ok {
continue
}
dst = append(dst, v)
seen[v.ID] = struct{}{}
if len(dst) >= limit {
return dst
}
}
return dst
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
+68
View File
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -98,6 +99,73 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
}
}
func TestHandleHomePrioritizesVideosWithReadyThumbnails(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 < 20; i++ {
id := "pending-video-" + strconv.Itoa(i)
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: id,
DriveID: "drive",
FileID: id,
Title: id,
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 pending video %s: %v", id, err)
}
}
for i := 0; i < homePageSize+2; 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+1) * time.Hour),
CreatedAt: now.Add(-time.Duration(i+1) * time.Hour),
UpdatedAt: now.Add(-time.Duration(i+1) * time.Hour),
}); err != nil {
t.Fatalf("seed ready video %s: %v", id, err)
}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/home", 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 !strings.HasPrefix(item.ID, "ready-video-") {
t.Fatalf("home returned %q without a ready thumbnail; items=%#v", item.ID, got)
}
if !strings.HasPrefix(item.Thumbnail, "https://thumb.example/") {
t.Fatalf("thumbnail for %q = %q, want ready thumbnail URL", item.ID, item.Thumbnail)
}
}
}
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+11 -7
View File
@@ -669,13 +669,14 @@ func (c *Catalog) FindVideoByFileSignature(ctx context.Context, fileName string,
}
type ListParams struct {
Keyword string
DriveID string
Tag string
Category string
Sort string // latest | hot | week | long
Page int
PageSize int
Keyword string
DriveID string
Tag string
Category string
Sort string // latest | hot | week | long
ThumbnailReadyOnly bool
Page int
PageSize int
}
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) {
@@ -710,6 +711,9 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int,
where = append(where, "category = ?")
args = append(args, p.Category)
}
if p.ThumbnailReadyOnly {
where = append(where, "COALESCE(thumbnail_url, '') != ''")
}
where = append(where, "COALESCE(hidden, 0) = 0")
where = append(where, uniqueVideoWhereSQL)
+53
View File
@@ -581,6 +581,59 @@ func TestListVideosHidesDuplicateContentHashes(t *testing.T) {
}
}
func TestListVideosCanFilterReadyThumbnails(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() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
for _, v := range []*Video{
{
ID: "ready-video",
DriveID: "drive",
FileID: "file-ready",
Title: "Ready",
ThumbnailURL: "/p/thumb/ready-video",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "pending-video",
DriveID: "drive",
FileID: "file-pending",
Title: "Pending",
PublishedAt: now.Add(time.Second),
CreatedAt: now.Add(time.Second),
UpdatedAt: now.Add(time.Second),
},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed video %s: %v", v.ID, err)
}
}
items, total, err := cat.ListVideos(ctx, ListParams{
Page: 1, PageSize: 10, ThumbnailReadyOnly: true,
})
if err != nil {
t.Fatalf("list videos: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("ready videos total=%d len=%d, want 1", total, len(items))
}
if items[0].ID != "ready-video" {
t.Fatalf("ready video id = %q, want ready-video", items[0].ID)
}
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
+829 -1531
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -15,14 +15,14 @@
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.26.2"
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.3",
"tsx": "^4.19.2",
"@vitejs/plugin-react": "^6.0.2",
"tsx": "^4.22.3",
"typescript": "5.6.3",
"vite": "5.4.10"
"vite": "^8.0.14"
}
}