mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Update homepage rotation and preview handling
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
## 当前功能
|
||||
|
||||
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。
|
||||
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。首页“今日排行”按点赞热度排序并每 2 小时轮换一组,默认展示 10 个;“最新视频”按发布时间倒序展示最新 10 个。
|
||||
- 视频卡片支持封面、画质、时长、点赞/点踩、移动端点按预览;列表页会记住筛选、分页和滚动位置。
|
||||
- 播放页会在视频信息中显示来源网盘类型,并提供点赞、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
|
||||
- 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 + "/" + fileID,value: 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)
|
||||
|
||||
@@ -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" }
|
||||
Generated
+1773
File diff suppressed because it is too large
Load Diff
+12
-11
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user