Update homepage rotation and preview handling

This commit is contained in:
Codex
2026-05-19 21:21:16 +08:00
parent 658a7ac086
commit 486b4c0235
14 changed files with 2508 additions and 41 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
## 当前功能
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。首页“今日排行”按点赞热度排序并每 2 小时轮换一组,默认展示 10 个;“最新视频”按发布时间倒序展示最新 10 个。
- 视频卡片支持封面、画质、时长、点赞/点踩、移动端点按预览;列表页会记住筛选、分页和滚动位置。
- 播放页会在视频信息中显示来源网盘类型,并提供点赞、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
- 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关。
+77
View File
@@ -153,6 +153,83 @@ func TestRegisterPreviewWorkersGenerateThumbnailsBeforePreviews(t *testing.T) {
t.Fatalf("generation did not finish, events=%#v", gen.Events())
}
func TestFailedThumbnailsDoNotBlockPreviewGeneration(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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()
video := &catalog.Video{
ID: "video-failed-thumb",
DriveID: "drive-id",
FileID: "file-1",
Title: "Clip With Failed Thumb",
PreviewStatus: "pending",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := cat.UpsertVideo(ctx, video); err != nil {
t.Fatalf("seed video: %v", err)
}
if err := cat.UpdateVideoMeta(ctx, video.ID, catalog.VideoMetaPatch{ThumbnailStatus: "failed"}); err != nil {
t.Fatalf("mark thumbnail failed: %v", err)
}
missing, err := cat.CountVideosNeedingThumbnail(ctx, "drive-id")
if err != nil {
t.Fatalf("count missing thumbnails: %v", err)
}
if missing != 0 {
t.Fatalf("missing thumbnails = %d, want failed thumbnails excluded", missing)
}
app := &App{
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
gen := &serverFakeTeaserGenerator{}
drv := &serverFakeDrive{}
worker := preview.NewWorker(gen, cat, drv, "")
thumbWorker := preview.NewThumbWorker(gen, cat, drv)
go worker.Run(ctx)
go thumbWorker.Run(ctx)
app.registerPreviewWorkers(ctx, "drive-id", worker, thumbWorker, func() {})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
got, err := cat.GetVideo(ctx, video.ID)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.PreviewStatus == "ready" {
events := gen.Events()
if len(events) != 1 || events[0] != "preview:"+video.ID {
t.Fatalf("events = %#v, want preview only", events)
}
return
}
time.Sleep(10 * time.Millisecond)
}
got, err := cat.GetVideo(ctx, video.ID)
if err != nil {
t.Fatalf("get video after timeout: %v", err)
}
t.Fatalf("preview status = %q, want ready; events=%#v", got.PreviewStatus, gen.Events())
}
func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+37 -2
View File
@@ -52,12 +52,18 @@ type Server struct {
LocalDir string
UploadDir string
FFmpegPath string
Now func() time.Time
OnVideoUploaded func(*catalog.Video)
transcodeMu sync.Mutex
transcodeJobs map[string]bool
}
const (
homePageSize = 10
homeWindowDuration = 2 * time.Hour
)
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
type VideoDTO struct {
ID string `json:"id"`
@@ -134,16 +140,45 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "hot", Page: 1, PageSize: 24,
items, total, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "hot", Page: 1, PageSize: homePageSize,
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
page := homeWindowPage(s.now(), total, homePageSize)
if page > 1 {
items, _, err = s.Catalog.ListVideos(r.Context(), catalog.ListParams{
Sort: "hot", Page: page, PageSize: homePageSize,
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
}
writeJSON(w, http.StatusOK, mapVideos(items))
}
func (s *Server) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func homeWindowPage(now time.Time, total, pageSize int) int {
if pageSize <= 0 || total <= pageSize {
return 1
}
pageCount := (total + pageSize - 1) / pageSize
if pageCount <= 1 {
return 1
}
window := now.Unix() / int64(homeWindowDuration/time.Second)
return int(window%int64(pageCount)) + 1
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
+92
View File
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -83,6 +84,78 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
}
}
func TestHomeWindowPageChangesEveryTwoHours(t *testing.T) {
total := homePageSize * 3
if got := homeWindowPage(time.Unix(0, 0), total, homePageSize); got != 1 {
t.Fatalf("window page at epoch = %d, want 1", got)
}
if got := homeWindowPage(time.Unix(int64(homeWindowDuration/time.Second)-1, 0), total, homePageSize); got != 1 {
t.Fatalf("window page before boundary = %d, want 1", got)
}
if got := homeWindowPage(time.Unix(int64(homeWindowDuration/time.Second), 0), total, homePageSize); got != 2 {
t.Fatalf("window page at first boundary = %d, want 2", got)
}
if got := homeWindowPage(time.Unix(int64(3*homeWindowDuration/time.Second), 0), total, homePageSize); got != 1 {
t.Fatalf("window page after cycle = %d, want 1", got)
}
}
func TestHandleHomeRotatesHotVideosByTwoHourWindow(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)
}
})
base := time.Unix(1_700_000_000, 0)
for i := 0; i < homePageSize+1; i++ {
id := "video-" + strconv.Itoa(i)
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: id,
DriveID: "drive",
FileID: "file-" + strconv.Itoa(i),
Title: "Video " + strconv.Itoa(i),
Likes: homePageSize + 1 - i,
PublishedAt: base.Add(time.Duration(i) * time.Second),
CreatedAt: base,
UpdatedAt: base,
}); err != nil {
t.Fatalf("seed video %s: %v", id, err)
}
}
firstWindow := requestHomeIDs(t, &Server{
Catalog: cat,
Now: func() time.Time { return time.Unix(0, 0) },
})
sameWindow := requestHomeIDs(t, &Server{
Catalog: cat,
Now: func() time.Time { return time.Unix(int64(homeWindowDuration/time.Second)-1, 0) },
})
nextWindow := requestHomeIDs(t, &Server{
Catalog: cat,
Now: func() time.Time { return time.Unix(int64(homeWindowDuration/time.Second), 0) },
})
if strings.Join(firstWindow, ",") != strings.Join(sameWindow, ",") {
t.Fatalf("same two-hour window changed videos: first=%v same=%v", firstWindow, sameWindow)
}
if strings.Join(firstWindow, ",") == strings.Join(nextWindow, ",") {
t.Fatalf("next two-hour window did not change videos: %v", nextWindow)
}
if len(firstWindow) != homePageSize {
t.Fatalf("first window item count = %d, want %d", len(firstWindow), homePageSize)
}
if len(nextWindow) != 1 {
t.Fatalf("next window item count = %d, want final page with 1 item", len(nextWindow))
}
}
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -652,6 +725,25 @@ func requestWithRouteParam(method, target, key, value string, body *strings.Read
return req
}
func requestHomeIDs(t *testing.T, server *Server) []string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/home", nil)
rr := httptest.NewRecorder()
server.handleHome(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("home status = %d, body = %s", rr.Code, rr.Body.String())
}
var videos []VideoDTO
if err := json.NewDecoder(rr.Body).Decode(&videos); err != nil {
t.Fatalf("decode home response: %v", err)
}
ids := make([]string, 0, len(videos))
for _, v := range videos {
ids = append(ids, v.ID)
}
return ids
}
func multipartUploadRequest(t *testing.T, fields map[string]string, fileName, fileContent string) *http.Request {
t.Helper()
var body bytes.Buffer
+4 -1
View File
@@ -325,7 +325,8 @@ func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status
return out, nil
}
// ListVideosNeedingThumbnail returns videos that do not have any cover URL yet.
// ListVideosNeedingThumbnail returns videos that still need a thumbnail attempt.
// Failed thumbnails are reported separately and should not block teaser generation.
func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
@@ -334,6 +335,7 @@ func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ?
AND COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') != 'failed'
AND COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
ORDER BY created_at ASC
@@ -360,6 +362,7 @@ func (c *Catalog) CountVideosNeedingThumbnail(ctx context.Context, driveID strin
`SELECT COUNT(*) FROM videos
WHERE drive_id = ?
AND COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') != 'failed'
AND COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL,
driveID).Scan(&count)
+16 -4
View File
@@ -170,12 +170,20 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error)
}
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
return d.streamURLWithUA(ctx, fileID, d.ua)
}
func (d *Driver) StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error) {
return d.streamURLWithUA(ctx, fileID, header.Get("User-Agent"))
}
func (d *Driver) streamURLWithUA(ctx context.Context, fileID string, ua string) (*drives.StreamLink, error) {
// 需要先拿到 pickCode
f, err := d.client.GetFile(fileID)
if err != nil {
return nil, fmt.Errorf("115 get file: %w", err)
}
info, ua, err := d.downloadInfo(f.PickCode)
info, ua, err := d.downloadInfo(f.PickCode, ua)
if err != nil {
return nil, fmt.Errorf("115 download url: %w", err)
}
@@ -201,12 +209,16 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
}, nil
}
func (d *Driver) downloadInfo(pickCode string) (*sdk.DownloadInfo, string, error) {
info, err := d.client.DownloadWithUA(pickCode, d.ua)
func (d *Driver) downloadInfo(pickCode string, ua string) (*sdk.DownloadInfo, string, error) {
ua = strings.TrimSpace(ua)
if ua == "" {
ua = d.ua
}
info, err := d.client.DownloadWithUA(pickCode, ua)
if err != nil {
return nil, "", err
}
return info, d.ua, nil
return info, ua, nil
}
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
+206 -13
View File
@@ -36,6 +36,8 @@ type Generator struct {
cfg Config
}
const teaserSegmentTimeout = 90 * time.Second
type ThumbnailGenerator interface {
Probe(ctx context.Context, link *drives.StreamLink) (float64, error)
GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error)
@@ -194,27 +196,88 @@ func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 {
return starts
}
// pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。
func pickThumbnailOffset() float64 {
return 5
func teaserCandidateStarts(duration float64, primary []float64, eachSec float64) []float64 {
out := make([]float64, 0, len(primary)+8)
for _, s := range primary {
out = appendUniqueStart(out, s, eachSec)
}
if duration <= 0 {
for _, s := range []float64{0, 3, 30, 60} {
out = appendUniqueStart(out, s, eachSec)
}
return out
}
usable := duration - eachSec - 1
if usable < 0 {
usable = 0
}
for _, pct := range []float64{0.03, 0.08, 0.12, 0.25, 0.40, 0.55, 0.70, 0.90} {
s := duration * pct
if s > usable {
s = usable
}
out = appendUniqueStart(out, s, eachSec)
}
return out
}
func appendUniqueStart(starts []float64, start, eachSec float64) []float64 {
if start < 0 {
start = 0
}
minGap := math.Max(1, eachSec*1.5)
for _, existing := range starts {
if math.Abs(existing-start) < minGap {
return starts
}
}
return append(starts, start)
}
// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于 teaser。
func thumbnailOffsets() []float64 {
return []float64{5, 1, 0}
}
// --- 封面 ---
// GenerateThumbnail 抽一张 jpg 封面。封面统一从第 5 秒抽帧,避免为封面单独探时长
// GenerateThumbnail 抽一张 jpg 封面。默认从第 5 秒抽帧,失败时回退到更早时间点
func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) {
dir := filepath.Join(g.cfg.LocalDir, "thumbs")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
dst := filepath.Join(dir, videoID+".jpg")
offset := pickThumbnailOffset()
var lastErr error
offsets := thumbnailOffsets()
for i, offset := range offsets {
if i > 0 {
_ = os.Remove(dst)
}
if err := g.generateThumbnailAtOffset(ctx, link, dst, offset); err != nil {
lastErr = err
if !thumbnailOffsetFallbackAllowed(err) {
return "", err
}
continue
}
return dst, nil
}
if lastErr != nil {
return "", lastErr
}
return "", errors.New("thumbnail generation did not run")
}
func (g *Generator) generateThumbnailAtOffset(ctx context.Context, link *drives.StreamLink, dst string, offset float64) error {
ctx2, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
ffmpegLink, cleanup, err := prepareFFmpegLink(ctx2, link)
if err != nil {
return "", err
return err
}
defer cleanup()
@@ -236,13 +299,23 @@ func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLi
out, err := cmd.CombinedOutput()
if err != nil {
os.Remove(dst)
return "", ffmpegCommandError("ffmpeg thumb", err, out)
return ffmpegCommandError("ffmpeg thumb", err, out)
}
if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 {
os.Remove(dst)
return "", fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out))
return fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out))
}
return dst, nil
return nil
}
func thumbnailOffsetFallbackAllowed(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "produced empty file") ||
strings.Contains(text, "signal: killed") ||
strings.Contains(text, "context deadline exceeded")
}
// --- 时长 ---
@@ -442,13 +515,35 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li
}
}()
for i, start := range starts {
candidates := teaserCandidateStarts(duration, starts, eachSec)
targetSegments := len(starts)
var lastErr error
for i, start := range candidates {
if len(segmentPaths) >= targetSegments {
break
}
seg, err := g.generateSingleSegment(ctx2, i, start, eachSec, linkForInput)
if err != nil {
return "", err
if !teaserSegmentFallbackAllowed(err) {
return "", err
}
lastErr = err
continue
}
segmentPaths = append(segmentPaths, seg)
}
if len(segmentPaths) == 0 {
if lastErr != nil {
return "", lastErr
}
return "", errors.New("no usable teaser segment")
}
if len(segmentPaths) < targetSegments {
if lastErr != nil {
return "", fmt.Errorf("only generated %d/%d teaser segments: %w", len(segmentPaths), targetSegments, lastErr)
}
return "", fmt.Errorf("only generated %d/%d teaser segments", len(segmentPaths), targetSegments)
}
if len(segmentPaths) == 1 {
success = true
@@ -513,6 +608,9 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li
}
func (g *Generator) generateSingleSegment(ctx context.Context, index int, start, eachSec float64, linkForInput func(int) (*drives.StreamLink, error)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, teaserSegmentTimeout)
defer cancel()
link, err := linkForInput(index)
if err != nil {
return "", err
@@ -570,6 +668,31 @@ func (g *Generator) generateSingleSegment(ctx context.Context, index int, start,
return segPath, nil
}
func teaserSegmentFallbackAllowed(err error) bool {
if err == nil {
return false
}
if _, ok := drives.RateLimitRetryAfter(err); ok {
return false
}
text := strings.ToLower(err.Error())
if strings.Contains(text, "server returned 403") ||
strings.Contains(text, "403 forbidden") ||
strings.Contains(text, "server returned 405") ||
strings.Contains(text, "405 method") ||
strings.Contains(text, "access denied") ||
strings.Contains(text, "request has been blocked") ||
strings.Contains(text, "访问被阻断") {
return false
}
return strings.Contains(text, "generated teaser has no video stream") ||
strings.Contains(text, "generated teaser has invalid duration") ||
strings.Contains(text, "generated teaser is empty") ||
strings.Contains(text, "produced empty file") ||
strings.Contains(text, "ffmpeg segment:") ||
strings.Contains(text, "ffprobe teaser:")
}
type localMediaProbe struct {
Streams []struct {
CodecType string `json:"codec_type"`
@@ -838,6 +961,7 @@ type Worker struct {
Catalog *catalog.Catalog
Drive drives.Drive
ch chan *catalog.Video
queue videoQueue
RateLimitCooldown time.Duration
BeforeTask func(context.Context) bool
@@ -858,10 +982,14 @@ func (w *Worker) Enqueue(v *catalog.Video) bool {
if v == nil {
return false
}
if !w.queue.reserve(v) {
return true
}
select {
case w.ch <- v:
return true
default:
w.queue.release(v)
return false
}
}
@@ -870,10 +998,14 @@ func (w *Worker) EnqueueBlocking(ctx context.Context, v *catalog.Video) bool {
if v == nil {
return false
}
if !w.queue.reserve(v) {
return true
}
select {
case w.ch <- v:
return true
case <-ctx.Done():
w.queue.release(v)
return false
}
}
@@ -883,6 +1015,7 @@ type ThumbWorker struct {
Catalog *catalog.Catalog
Drive drives.Drive
ch chan *catalog.Video
queue videoQueue
RateLimitCooldown time.Duration
rateLimit rateLimitState
@@ -915,6 +1048,54 @@ type taskActivity struct {
currentTitle string
}
type videoQueue struct {
mu sync.Mutex
ids map[string]struct{}
}
func (q *videoQueue) reserve(v *catalog.Video) bool {
if v == nil {
return false
}
if v.ID == "" {
return true
}
q.mu.Lock()
defer q.mu.Unlock()
if q.ids == nil {
q.ids = make(map[string]struct{})
}
if _, ok := q.ids[v.ID]; ok {
return false
}
q.ids[v.ID] = struct{}{}
return true
}
func (q *videoQueue) release(v *catalog.Video) {
if v == nil || v.ID == "" {
return
}
q.mu.Lock()
delete(q.ids, v.ID)
q.mu.Unlock()
}
func (q *videoQueue) lengthExcluding(currentID string) int {
q.mu.Lock()
defer q.mu.Unlock()
n := len(q.ids)
if currentID != "" {
if _, ok := q.ids[currentID]; ok {
n--
}
}
if n < 0 {
return 0
}
return n
}
func (a *taskActivity) start(v *catalog.Video) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -991,10 +1172,14 @@ func (w *ThumbWorker) Enqueue(v *catalog.Video) bool {
if v == nil {
return false
}
if !w.queue.reserve(v) {
return true
}
select {
case w.ch <- v:
return true
default:
w.queue.release(v)
return false
}
}
@@ -1003,10 +1188,14 @@ func (w *ThumbWorker) EnqueueBlocking(ctx context.Context, v *catalog.Video) boo
if v == nil {
return false
}
if !w.queue.reserve(v) {
return true
}
select {
case w.ch <- v:
return true
case <-ctx.Done():
w.queue.release(v)
return false
}
}
@@ -1015,14 +1204,16 @@ func (w *Worker) Status() TaskStatus {
if w == nil {
return TaskStatus{State: "idle"}
}
return taskStatus(&w.activity, &w.rateLimit, len(w.ch))
currentID, _ := w.activity.current()
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID))
}
func (w *ThumbWorker) Status() TaskStatus {
if w == nil {
return TaskStatus{State: "idle"}
}
return taskStatus(&w.activity, &w.rateLimit, len(w.ch))
currentID, _ := w.activity.current()
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID))
}
func taskStatus(activity *taskActivity, rateLimit *rateLimitState, queueLength int) TaskStatus {
@@ -1085,6 +1276,7 @@ func (w *ThumbWorker) Run(ctx context.Context) {
}
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
defer w.queue.release(v)
if w.BeforeTask != nil && !w.BeforeTask(ctx) {
return
}
@@ -1098,6 +1290,7 @@ func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
}
func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) {
defer w.queue.release(v)
w.activity.start(v)
defer w.activity.done()
if !waitForRateLimitCooldown(ctx, &w.rateLimit, "thumb", w.Drive) {
+64
View File
@@ -1,6 +1,7 @@
package preview
import (
"context"
"errors"
"math"
"net/http"
@@ -94,6 +95,69 @@ func TestTinyVideoPreviewPlanUsesWholeVideoAsSingleSegment(t *testing.T) {
}
}
func TestTeaserCandidateStartsKeepPrimaryAndAddFallbacks(t *testing.T) {
primary := []float64{10.2, 64.65, 119.1, 173.55}
got := teaserCandidateStarts(204, primary, 3)
if len(got) <= len(primary) {
t.Fatalf("candidate starts = %#v, want fallback starts after primary", got)
}
for i, want := range primary {
if math.Abs(got[i]-want) > 0.001 {
t.Fatalf("candidate[%d] = %.2f, want primary %.2f first", i, got[i], want)
}
}
}
func TestTeaserSegmentFallbackAllowedForBadSegmentOutput(t *testing.T) {
for _, err := range []error{
errors.New("generated teaser has no video stream"),
errors.New("ffmpeg segment: signal: killed, stderr: "),
errors.New("ffmpeg segment produced empty file, stderr: "),
} {
if !teaserSegmentFallbackAllowed(err) {
t.Fatalf("teaserSegmentFallbackAllowed(%v) = false, want true", err)
}
}
if teaserSegmentFallbackAllowed(errors.New("server returned 403 forbidden")) {
t.Fatal("403 errors should not trigger teaser segment fallback")
}
}
func TestTeaserSegmentFallbackRequiresPlannedSegmentCount(t *testing.T) {
err := errors.New("only generated 2/4 teaser segments: generated teaser has no video stream")
if !strings.Contains(err.Error(), "2/4") {
t.Fatalf("error = %v, want generated/planned segment count", err)
}
}
func TestThumbnailOffsetsUseFiveSecondsWithEarlyFallbacks(t *testing.T) {
got := thumbnailOffsets()
want := []float64{5, 1, 0}
if len(got) != len(want) {
t.Fatalf("offsets = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("offset[%d] = %.2f, want %.2f", i, got[i], want[i])
}
}
}
func TestThumbnailOffsetFallbackAllowedForEmptyOutputAndTimeouts(t *testing.T) {
for _, err := range []error{
errors.New("ffmpeg thumb produced empty file, stderr: "),
errors.New("ffmpeg thumb: signal: killed, stderr: "),
context.DeadlineExceeded,
} {
if !thumbnailOffsetFallbackAllowed(err) {
t.Fatalf("thumbnailOffsetFallbackAllowed(%v) = false, want true", err)
}
}
if thumbnailOffsetFallbackAllowed(errors.New("server returned 403 forbidden")) {
t.Fatal("403 errors should not trigger thumbnail offset fallback")
}
}
func TestFFmpeg429OutputBecomesRateLimitError(t *testing.T) {
err := ffmpegCommandError("ffmpeg", errors.New("exit status 8"), []byte("Server returned 429 Too Many Requests"))
var rateLimit *drives.RateLimitError
+68
View File
@@ -109,6 +109,74 @@ func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testin
}
}
func TestPreviewWorkerDeduplicatesQueuedVideos(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "preview-dedupe-video")
gen := &fakeTeaserGenerator{}
drv := &previewFakeDrive{}
worker := NewWorker(gen, cat, drv, "")
if !worker.EnqueueBlocking(ctx, video) {
t.Fatal("first enqueue returned false, want true")
}
if !worker.EnqueueBlocking(ctx, video) {
t.Fatal("duplicate enqueue returned false, want idempotent success")
}
if got := worker.Status().QueueLength; got != 1 {
t.Fatalf("queue length = %d, want 1 unique video", got)
}
queued := <-worker.ch
if !worker.Enqueue(video) {
t.Fatal("enqueue while the same video is reserved returned false, want idempotent success")
}
select {
case <-worker.ch:
t.Fatal("duplicate enqueue added another queued video")
default:
}
worker.processQueued(ctx, queued)
if !worker.Enqueue(video) {
t.Fatal("enqueue after processing returned false, want true")
}
}
func TestThumbWorkerDeduplicatesQueuedVideos(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-dedupe-video")
gen := &fakeThumbGenerator{}
drv := &previewFakeDrive{}
worker := NewThumbWorker(gen, cat, drv)
if !worker.Enqueue(video) {
t.Fatal("first enqueue returned false, want true")
}
if !worker.Enqueue(video) {
t.Fatal("duplicate enqueue returned false, want idempotent success")
}
if got := worker.Status().QueueLength; got != 1 {
t.Fatalf("queue length = %d, want 1 unique video", got)
}
queued := <-worker.ch
if !worker.Enqueue(video) {
t.Fatal("enqueue while the same thumbnail is reserved returned false, want idempotent success")
}
select {
case <-worker.ch:
t.Fatal("duplicate enqueue added another queued thumbnail")
default:
}
worker.processQueued(ctx, queued)
if !worker.Enqueue(video) {
t.Fatal("enqueue after release returned false, want true")
}
}
func TestPreviewWorkerRemovesPreviousLocalTeaserAfterNewTeaserIsReady(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "preview-cleanup-video")
+44 -8
View File
@@ -11,6 +11,10 @@ import (
"github.com/video-site/backend/internal/drives"
)
type streamURLWithHeader interface {
StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error)
}
// Registry 管理多个 Drive 实例
type Registry struct {
mu sync.RWMutex
@@ -53,7 +57,7 @@ func (r *Registry) Remove(id string) {
// Proxy 根据 driveID + fileID 反向代理到真实网盘直链
type Proxy struct {
Registry *Registry
// linkCache key: driveID + "/" + fileIDvalue: cachedLink
// linkCache key: driveID + "/" + fileID (+ User-Agent for UA-bound links)
cacheMu sync.Mutex
cache map[string]cachedLink
http *http.Client
@@ -74,8 +78,8 @@ func New(r *Registry) *Proxy {
}
}
func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) {
key := driveID + "/" + fileID
func (p *Proxy) getLink(ctx context.Context, d drives.Drive, driveID, fileID string, header http.Header) (*drives.StreamLink, error) {
key := linkCacheKey(d, driveID, fileID, header)
p.cacheMu.Lock()
if c, ok := p.cache[key]; ok {
@@ -87,11 +91,15 @@ func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.St
}
p.cacheMu.Unlock()
d, ok := p.Registry.Get(driveID)
if !ok {
return nil, errDriveNotFound
var (
link *drives.StreamLink
err error
)
if h, ok := d.(streamURLWithHeader); ok {
link, err = h.StreamURLWithHeader(ctx, fileID, header)
} else {
link, err = d.StreamURL(ctx, fileID)
}
link, err := d.StreamURL(ctx, fileID)
if err != nil {
return nil, err
}
@@ -101,15 +109,43 @@ func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.St
return link, nil
}
func linkCacheKey(d drives.Drive, driveID, fileID string, header http.Header) string {
key := driveID + "/" + fileID
if _, ok := d.(streamURLWithHeader); ok {
key += "|ua=" + header.Get("User-Agent")
}
return key
}
func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) {
link, err := p.getLink(r.Context(), driveID, fileID)
d, ok := p.Registry.Get(driveID)
if !ok {
http.Error(w, errDriveNotFound.Error(), errDriveNotFound.Code)
return
}
link, err := p.getLink(r.Context(), d, driveID, fileID, r.Header)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if shouldRedirect(d) {
redirect(w, r, link)
return
}
p.serve(w, r, link)
}
func shouldRedirect(d drives.Drive) bool {
return d.Kind() == "p115"
}
func redirect(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate")
http.Redirect(w, r, link.URL, http.StatusFound)
}
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
// 构造上游请求
u, err := url.Parse(link.URL)
+109
View File
@@ -0,0 +1,109 @@
package proxy
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/video-site/backend/internal/drives"
)
func TestServeStreamRedirectsP115WithRequestUserAgent(t *testing.T) {
reg := NewRegistry()
drv := &proxyFakeDrive{kind: "p115"}
reg.Set("115", drv)
p := New(reg)
req := httptest.NewRequest(http.MethodGet, "/p/stream/115/file-1", nil)
req.Header.Set("User-Agent", "Browser-A")
rr := httptest.NewRecorder()
p.ServeStream(rr, req, "115", "file-1")
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
}
if got := rr.Header().Get("Location"); got != "https://cdn.example/file-1?ua=Browser-A" {
t.Fatalf("Location = %q", got)
}
if got := drv.calls[0].ua; got != "Browser-A" {
t.Fatalf("link UA = %q, want request UA", got)
}
if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Fatalf("Referrer-Policy = %q", got)
}
}
func TestServeStreamP115CacheIsUserAgentScoped(t *testing.T) {
reg := NewRegistry()
drv := &proxyFakeDrive{kind: "p115"}
reg.Set("115", drv)
p := New(reg)
requestP115(t, p, "115", "file-1", "Browser-A")
requestP115(t, p, "115", "file-1", "Browser-B")
requestP115(t, p, "115", "file-1", "Browser-A")
if len(drv.calls) != 2 {
t.Fatalf("link calls = %d, want 2", len(drv.calls))
}
if drv.calls[0].ua != "Browser-A" || drv.calls[1].ua != "Browser-B" {
t.Fatalf("link UAs = %#v", drv.calls)
}
}
func requestP115(t *testing.T, p *Proxy, driveID, fileID, ua string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/p/stream/"+driveID+"/"+fileID, nil)
req.Header.Set("User-Agent", ua)
rr := httptest.NewRecorder()
p.ServeStream(rr, req, driveID, fileID)
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
}
}
type proxyFakeDrive struct {
kind string
calls []proxyFakeCall
}
type proxyFakeCall struct {
fileID string
ua string
}
func (d *proxyFakeDrive) Kind() string { return d.kind }
func (d *proxyFakeDrive) ID() string { return "fake" }
func (d *proxyFakeDrive) Init(context.Context) error {
return nil
}
func (d *proxyFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *proxyFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *proxyFakeDrive) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
return d.StreamURLWithHeader(ctx, fileID, nil)
}
func (d *proxyFakeDrive) StreamURLWithHeader(_ context.Context, fileID string, header http.Header) (*drives.StreamLink, error) {
ua := header.Get("User-Agent")
d.calls = append(d.calls, proxyFakeCall{fileID: fileID, ua: ua})
return &drives.StreamLink{
URL: "https://cdn.example/" + fileID + "?ua=" + ua,
Headers: http.Header{"User-Agent": {ua}},
Expires: time.Now().Add(time.Minute),
}, nil
}
func (d *proxyFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *proxyFakeDrive) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *proxyFakeDrive) RootID() string { return "0" }
+1773
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -5,20 +5,25 @@ import { SearchPanel } from "@/components/SearchPanel";
import { TagCloud } from "@/components/TagCloud";
import { SectionHeader } from "@/components/SectionHeader";
import { VideoGrid } from "@/components/VideoGrid";
import { fetchHomeVideos } from "@/data/videos";
import { fetchHomeVideos, fetchListing } from "@/data/videos";
import type { VideoItem } from "@/types";
export default function HomePage() {
const [videos, setVideos] = useState<VideoItem[]>([]);
const [rankingVideos, setRankingVideos] = useState<VideoItem[]>([]);
const [latestVideos, setLatestVideos] = useState<VideoItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
document.title = "首页 · 视频聚合站";
let active = true;
setLoading(true);
fetchHomeVideos().then((items) => {
Promise.all([
fetchHomeVideos(),
fetchListing(1, 10, { sort: "latest" }),
]).then(([rankingItems, latestResult]) => {
if (!active) return;
setVideos(items);
setRankingVideos(rankingItems);
setLatestVideos(latestResult.items);
setLoading(false);
});
return () => {
@@ -35,17 +40,13 @@ export default function HomePage() {
</div>
<div className="container page-section">
<SectionHeader title="今日排行" extra={`精选 ${videos.length} 个作品`} />
<VideoGrid videos={videos} loading={loading} skeletonCount={12} />
<SectionHeader title="今日排行" extra={`精选 ${rankingVideos.length} 个作品`} />
<VideoGrid videos={rankingVideos} loading={loading} skeletonCount={12} />
</div>
<div className="container page-section">
<SectionHeader title="最新视频" />
<VideoGrid
videos={loading ? [] : videos.slice().reverse()}
loading={loading}
skeletonCount={12}
/>
<VideoGrid videos={latestVideos} loading={loading} skeletonCount={12} />
</div>
</AppShell>
);
+5 -1
View File
@@ -3,6 +3,10 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export HOME="${HOME:-/root}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
export GOCACHE="${GOCACHE:-/tmp/video-site-91/go-build}"
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
FRONTEND_PORT="${FRONTEND_PORT:-9191}"
FRONTEND_MODE="${FRONTEND_MODE:-preview}"
@@ -101,7 +105,7 @@ start_backend() {
fi
need_cmd go
mkdir -p "$LOG_DIR"
mkdir -p "$LOG_DIR" "$GOCACHE"
echo "starting backend on 127.0.0.1:$BACKEND_PORT"
(
cd "$ROOT_DIR/backend"