Refine shorts playback caching

Remove shorts recommendation preference, keep a six-video viewed cache window, preload the next two videos after healthy buffering, and avoid premature seen-list resets between sessions.
This commit is contained in:
nianzhibai
2026-06-13 16:26:36 +08:00
parent 1ae1408fb6
commit 76782f3801
7 changed files with 235 additions and 451 deletions
+12 -18
View File
@@ -530,11 +530,9 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
}
// shortsNextReq 客户端把当前轮已看过的 video id 列表传上来。
// PreferredFromVideoID 来自短视频页最近一次点赞成功的视频,用于优先推荐相似标签。
type shortsNextReq struct {
SeenIDs []string `json:"seenIds"`
Count int `json:"count"`
PreferredFromVideoID string `json:"preferredFromVideoId"`
SeenIDs []string `json:"seenIds"`
Count int `json:"count"`
}
// ShortsItemDTO 是短视频流单条的精简结构。比 VideoDTO 多 videoSrc / poster
@@ -552,8 +550,8 @@ type ShortsItemDTO struct {
// - 服务器从未在 seenIds 中的可见视频里随机抽至多 count 条返回
// - 当返回数量 < count 且小于全库可见总数时,说明本轮即将结束,
// 返回 roundComplete=true,前端应在用户看完返回的这些后清空本地已看记录开新一轮
// - 当 seenIds 已经覆盖全库时,本接口直接返回新一轮的随机一批
// seenIds=[] 即可让客户端在轮次完成后重新开始
// - 当 seenIds 真实覆盖当前全部可见视频时,本接口直接返回新一轮的随机一批
// 不能仅看 seenIds 长度,里面可能有隐藏、删除或历史脏 ID
func (s *Server) handleShortsNext(w http.ResponseWriter, r *http.Request) {
var body shortsNextReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
@@ -574,22 +572,18 @@ func (s *Server) handleShortsNext(w http.ResponseWriter, r *http.Request) {
return
}
// 如果客户端已看记录已经 ≥ 全库,则视为新一轮,直接忽略 seenIds
exclude := body.SeenIDs
if total > 0 && len(exclude) >= total {
exclude = nil
}
var items []*catalog.Video
if strings.TrimSpace(body.PreferredFromVideoID) != "" {
items, err = s.Catalog.RandomVideosForPreferredVideoExcluding(r.Context(), body.PreferredFromVideoID, exclude, count)
} else {
items, err = s.Catalog.RandomVideosExcluding(r.Context(), exclude, count)
}
items, err := s.Catalog.RandomVideosExcluding(r.Context(), body.SeenIDs, count)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if total > 0 && len(items) == 0 && len(body.SeenIDs) > 0 {
items, err = s.Catalog.RandomVideosExcluding(r.Context(), nil, count)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
}
// 注入 sourceLabel 以便前端展示来源网盘
driveLabels := make(map[string]string)
+73 -6
View File
@@ -810,7 +810,7 @@ func TestHandleTagsReturnsUnifiedTagPool(t *testing.T) {
}
}
func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
func TestHandleShortsNextReturnsRandomBatchExcludingSeen(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
@@ -834,7 +834,7 @@ func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
}
}
req := httptest.NewRequest(http.MethodPost, "/api/shorts/next", strings.NewReader(`{"seenIds":["current"],"count":3,"preferredFromVideoId":"current"}`))
req := httptest.NewRequest(http.MethodPost, "/api/shorts/next", strings.NewReader(`{"seenIds":["current"],"count":3}`))
rr := httptest.NewRecorder()
(&Server{Catalog: cat}).handleShortsNext(rr, req)
@@ -857,10 +857,7 @@ func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
t.Fatalf("total = %d, want 4", got.Total)
}
if got.RoundComplete {
t.Fatalf("roundComplete = true, want false with fallback-filled batch")
}
if !containsString(ids, "rare-1") {
t.Fatalf("ids = %#v, want rare-1 from least populated tag", ids)
t.Fatalf("roundComplete = true, want false with a full remaining batch")
}
if containsString(ids, "current") {
t.Fatalf("ids = %#v, should exclude current", ids)
@@ -868,6 +865,76 @@ func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
if len(ids) != 3 {
t.Fatalf("ids = %#v, want 3 items", ids)
}
for _, want := range []string{"common-1", "common-2", "rare-1"} {
if !containsString(ids, want) {
t.Fatalf("ids = %#v, want remaining id %s", ids, want)
}
}
}
func TestHandleShortsNextDoesNotResetForStaleSeenIDs(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 _, v := range []*catalog.Video{
{ID: "seen-1", DriveID: "drive", FileID: "f-seen-1", Title: "seen 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "fresh-1", DriveID: "drive", FileID: "f-fresh-1", Title: "fresh 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "fresh-2", DriveID: "drive", FileID: "f-fresh-2", Title: "fresh 2", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "hidden-1", DriveID: "drive", FileID: "f-hidden-1", Title: "hidden 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
if err := cat.HideVideo(ctx, "hidden-1"); err != nil {
t.Fatalf("hide hidden-1: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/shorts/next", strings.NewReader(`{"seenIds":["seen-1","hidden-1","deleted-stale"],"count":3}`))
rr := httptest.NewRecorder()
(&Server{Catalog: cat}).handleShortsNext(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got struct {
Items []ShortsItemDTO `json:"items"`
Total int `json:"total"`
RoundComplete bool `json:"roundComplete"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
ids := make([]string, 0, len(got.Items))
for _, item := range got.Items {
ids = append(ids, item.ID)
}
if got.Total != 3 {
t.Fatalf("total = %d, want 3", got.Total)
}
if !got.RoundComplete {
t.Fatalf("roundComplete = false, want true after returning all unviewed visible videos")
}
if containsString(ids, "seen-1") || containsString(ids, "hidden-1") {
t.Fatalf("ids = %#v, should not reset and return seen or hidden videos", ids)
}
for _, want := range []string{"fresh-1", "fresh-2"} {
if !containsString(ids, want) {
t.Fatalf("ids = %#v, want %s", ids, want)
}
}
if len(ids) != 2 {
t.Fatalf("ids = %#v, want exactly the two unviewed visible videos", ids)
}
}
func TestHandleUpdateVideoTagsRejectsUnknownTags(t *testing.T) {
+36 -190
View File
@@ -51,44 +51,44 @@ func (c *Catalog) Close() error { return c.db.Close() }
// ---------- Video ----------
type Video struct {
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ContentHash string `json:"contentHash"`
SampledSHA256 string `json:"sampledSha256"`
FingerprintStatus string `json:"fingerprintStatus"`
FingerprintError string `json:"fingerprintError"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Author string `json:"author"`
Tags []string `json:"tags"`
DurationSeconds int `json:"durationSeconds"`
Size int64 `json:"size"`
Ext string `json:"ext"`
Quality string `json:"quality"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewFileID string `json:"previewFileId"`
PreviewLocal string `json:"previewLocal"`
PreviewStatus string `json:"previewStatus"`
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ContentHash string `json:"contentHash"`
SampledSHA256 string `json:"sampledSha256"`
FingerprintStatus string `json:"fingerprintStatus"`
FingerprintError string `json:"fingerprintError"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Author string `json:"author"`
Tags []string `json:"tags"`
DurationSeconds int `json:"durationSeconds"`
Size int64 `json:"size"`
Ext string `json:"ext"`
Quality string `json:"quality"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewFileID string `json:"previewFileId"`
PreviewLocal string `json:"previewLocal"`
PreviewStatus string `json:"previewStatus"`
// TranscodeStatus:浏览器兼容性转码状态。
// ''=未检测 / pending=已入队 / ready=已转码 / skipped=无需转码 / failed=失败。
TranscodeStatus string `json:"transcodeStatus"`
TranscodeError string `json:"transcodeError"`
TranscodedFileID string `json:"transcodedFileId"`
TranscodedSize int64 `json:"transcodedSize"`
Views int `json:"views"`
Favorites int `json:"favorites"`
Comments int `json:"comments"`
Likes int `json:"likes"`
Dislikes int `json:"dislikes"`
Category string `json:"category"`
Hidden bool `json:"hidden"`
Badges []string `json:"badges"`
Description string `json:"description"`
PublishedAt time.Time `json:"publishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
TranscodeStatus string `json:"transcodeStatus"`
TranscodeError string `json:"transcodeError"`
TranscodedFileID string `json:"transcodedFileId"`
TranscodedSize int64 `json:"transcodedSize"`
Views int `json:"views"`
Favorites int `json:"favorites"`
Comments int `json:"comments"`
Likes int `json:"likes"`
Dislikes int `json:"dislikes"`
Category string `json:"category"`
Hidden bool `json:"hidden"`
Badges []string `json:"badges"`
Description string `json:"description"`
PublishedAt time.Time `json:"publishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
@@ -1533,160 +1533,6 @@ func cleanVideoIDs(ids []string) []string {
return cleaned
}
func cleanTagLabels(labels []string) []string {
seen := make(map[string]struct{}, len(labels))
cleaned := make([]string, 0, len(labels))
for _, label := range labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
key := strings.ToLower(label)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
cleaned = append(cleaned, label)
}
return cleaned
}
func (c *Catalog) LeastPopulatedVisibleUniqueTag(ctx context.Context, labels []string) (string, error) {
cleaned := cleanTagLabels(labels)
bestLabel := ""
bestCount := 0
for _, label := range cleaned {
var count int
if err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+activeDriveWhereSQL+`
AND `+uniqueVideoWhereSQL+`
AND EXISTS (
SELECT 1
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = videos.id
AND t.label = ? COLLATE NOCASE
)`,
label,
).Scan(&count); err != nil {
return "", err
}
if count == 0 {
continue
}
if bestLabel == "" || count < bestCount {
bestLabel = label
bestCount = count
}
}
return bestLabel, nil
}
func (c *Catalog) RandomVideosByTagExcluding(ctx context.Context, tag string, excludeIDs []string, limit int) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
tag = strings.TrimSpace(tag)
if tag == "" {
return nil, nil
}
cleaned := cleanVideoIDs(excludeIDs)
args := make([]any, 0, len(cleaned)+2)
args = append(args, tag)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + activeDriveWhereSQL + `
AND ` + uniqueVideoWhereSQL + `
AND EXISTS (
SELECT 1
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = videos.id
AND t.label = ? COLLATE NOCASE
)`
if len(cleaned) > 0 {
placeholders := strings.Repeat("?,", len(cleaned))
placeholders = placeholders[:len(placeholders)-1]
whereSQL += " AND id NOT IN (" + placeholders + ")"
for _, id := range cleaned {
args = append(args, id)
}
}
args = append(args, limit)
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos `+whereSQL+`
ORDER BY RANDOM() LIMIT ?`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c *Catalog) RandomVideosForPreferredVideoExcluding(ctx context.Context, preferredVideoID string, excludeIDs []string, limit int) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
preferredVideoID = strings.TrimSpace(preferredVideoID)
if preferredVideoID == "" {
return c.RandomVideosExcluding(ctx, excludeIDs, limit)
}
preferredExclude := append([]string{}, excludeIDs...)
preferredExclude = append(preferredExclude, preferredVideoID)
preferred, err := c.GetVideo(ctx, preferredVideoID)
if err != nil || preferred == nil || preferred.Hidden || len(preferred.Tags) == 0 {
return c.RandomVideosExcluding(ctx, preferredExclude, limit)
}
tag, err := c.LeastPopulatedVisibleUniqueTag(ctx, preferred.Tags)
if err != nil {
return nil, err
}
if tag == "" {
return c.RandomVideosExcluding(ctx, preferredExclude, limit)
}
items, err := c.RandomVideosByTagExcluding(ctx, tag, preferredExclude, limit)
if err != nil {
return nil, err
}
if len(items) >= limit {
return items, nil
}
mergedExclude := make([]string, 0, len(preferredExclude)+len(items))
mergedExclude = append(mergedExclude, preferredExclude...)
for _, item := range items {
if item != nil {
mergedExclude = append(mergedExclude, item.ID)
}
}
fallback, err := c.RandomVideosExcluding(ctx, mergedExclude, limit-len(items))
if err != nil {
return nil, err
}
return append(items, fallback...), nil
}
type DriveTeaserCounts struct {
Ready int
Pending int
-168
View File
@@ -165,171 +165,3 @@ func TestRandomVideosWithReadyThumbnailsExcluding(t *testing.T) {
}
}
}
func TestRandomVideosForPreferredVideoChoosesLeastPopulatedTag(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 _, v := range []*Video{
{ID: "current", DriveID: "drive", FileID: "f-current", Title: "current", Tags: []string{"common", "rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-1", DriveID: "drive", FileID: "f-common-1", Title: "common 1", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-2", DriveID: "drive", FileID: "f-common-2", Title: "common 2", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "rare-1", DriveID: "drive", FileID: "f-rare-1", Title: "rare 1", Tags: []string{"rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
tag, err := cat.LeastPopulatedVisibleUniqueTag(ctx, []string{"common", "rare"})
if err != nil {
t.Fatalf("least populated tag: %v", err)
}
if tag != "rare" {
t.Fatalf("least populated tag = %q, want rare", tag)
}
got, err := cat.RandomVideosForPreferredVideoExcluding(ctx, "current", []string{"current"}, 1)
if err != nil {
t.Fatalf("random preferred: %v", err)
}
if len(got) != 1 || got[0].ID != "rare-1" {
t.Fatalf("preferred result = %#v, want rare-1", videoIDs(got))
}
got, err = cat.RandomVideosForPreferredVideoExcluding(ctx, "current", nil, 1)
if err != nil {
t.Fatalf("random preferred without explicit exclude: %v", err)
}
if len(got) != 1 || got[0].ID == "current" {
t.Fatalf("preferred result without explicit exclude = %#v, should not return current", videoIDs(got))
}
}
func TestRandomVideosForPreferredVideoFallsBackToFillBatch(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 _, v := range []*Video{
{ID: "current", DriveID: "drive", FileID: "f-current", Title: "current", Tags: []string{"common", "rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-1", DriveID: "drive", FileID: "f-common-1", Title: "common 1", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-2", DriveID: "drive", FileID: "f-common-2", Title: "common 2", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "rare-1", DriveID: "drive", FileID: "f-rare-1", Title: "rare 1", Tags: []string{"rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "hidden-rare", DriveID: "drive", FileID: "f-hidden-rare", Title: "hidden rare", Tags: []string{"rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
if err := cat.HideVideo(ctx, "hidden-rare"); err != nil {
t.Fatalf("hide hidden-rare: %v", err)
}
got, err := cat.RandomVideosForPreferredVideoExcluding(ctx, "current", []string{"current"}, 3)
if err != nil {
t.Fatalf("random preferred: %v", err)
}
ids := videoIDs(got)
if len(ids) != 3 {
t.Fatalf("result ids = %#v, want 3 items", ids)
}
for _, excluded := range []string{"current", "hidden-rare"} {
if hasVideoID(ids, excluded) {
t.Fatalf("result ids = %#v, should not include %s", ids, excluded)
}
}
if !hasVideoID(ids, "rare-1") {
t.Fatalf("result ids = %#v, want rare-1 from least populated tag", ids)
}
if len(uniqueVideoIDs(ids)) != len(ids) {
t.Fatalf("result ids = %#v, want no duplicates", ids)
}
}
func TestRandomVideosForPreferredVideoFallbacksWhenPreferenceUnavailable(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 _, v := range []*Video{
{ID: "untagged", DriveID: "drive", FileID: "f-untagged", Title: "untagged", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "visible-1", DriveID: "drive", FileID: "f-visible-1", Title: "visible 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "visible-2", DriveID: "drive", FileID: "f-visible-2", Title: "visible 2", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
got, err := cat.RandomVideosForPreferredVideoExcluding(ctx, "missing", []string{"untagged"}, 2)
if err != nil {
t.Fatalf("random missing preferred: %v", err)
}
if !sameVideoIDSet(videoIDs(got), []string{"visible-1", "visible-2"}) {
t.Fatalf("missing preferred ids = %#v, want visible fallback videos", videoIDs(got))
}
got, err = cat.RandomVideosForPreferredVideoExcluding(ctx, "untagged", []string{"untagged"}, 2)
if err != nil {
t.Fatalf("random untagged preferred: %v", err)
}
if !sameVideoIDSet(videoIDs(got), []string{"visible-1", "visible-2"}) {
t.Fatalf("untagged preferred ids = %#v, want visible fallback videos", videoIDs(got))
}
}
func videoIDs(videos []*Video) []string {
ids := make([]string, 0, len(videos))
for _, v := range videos {
ids = append(ids, v.ID)
}
return ids
}
func hasVideoID(ids []string, want string) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
func uniqueVideoIDs(ids []string) map[string]struct{} {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
seen[id] = struct{}{}
}
return seen
}
func sameVideoIDSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
seen := make(map[string]int, len(a))
for _, value := range a {
seen[value]++
}
for _, value := range b {
if seen[value] == 0 {
return false
}
seen[value]--
}
return true
}
+3 -5
View File
@@ -132,19 +132,17 @@ export type ShortsNextResponse = {
/**
* 拉取短视频流的下一批候选。把当前轮已看过的 video id 列表传给后端,
* 服务器从未在列表中的视频里随机抽 count 条返回。preferredFromVideoId
* 来自用户最近一次点赞成功的视频,用于按相似标签优先推荐。
* 服务器从未在列表中的视频里随机抽 count 条返回。
*
* 失败时返回空批 + roundComplete=false,由调用方决定是否重试。
*/
export function fetchShortsNext(
seenIds: string[],
count: number,
preferredFromVideoId?: string
count: number
): Promise<ShortsNextResponse> {
return apiJSON<ShortsNextResponse>("/api/shorts/next", {
method: "POST",
body: JSON.stringify({ seenIds, count, preferredFromVideoId }),
body: JSON.stringify({ seenIds, count }),
}).catch(() => ({ items: [], total: 0, roundComplete: false }));
}
+61 -49
View File
@@ -32,18 +32,21 @@ const BATCH_SIZE = 5;
// 当队列里"还没看过的视频"少于这个数时,提前请求下一批。
const PREFETCH_THRESHOLD = 2;
// 当前视频至少有这么多秒的前向缓冲后,才允许下一条开始预加载。
// 当前视频至少有这么多秒的前向缓冲后,才允许后续视频开始预加载。
const ACTIVE_PRELOAD_BUFFER_SECONDS = 12;
// 当前视频流畅播放后,向后预加载多少条视频。
const PRELOAD_AHEAD_COUNT = 2;
// 预加载授权一旦发出,只有当前视频前向缓冲跌破这个秒数(或发生 stall)
// 才收回。高低水位之间不动作,避免缓冲量在 12s 附近波动时
// 反复绑定/剥离下一条的 src、丢弃已预加载的数据。
// 反复绑定/剥离后续视频的 src、丢弃已预加载的数据。
const ACTIVE_PRELOAD_KEEP_SECONDS = 4;
// 距离 activeIndex 多少屏内的视频会被 mount 真实 <video> 壳。
// 当前屏先绑定 src下一屏要等当前屏缓冲健康后才预加载
// 上一屏如果已经可播放过,保留 src 复用浏览器缓
const MOUNT_RADIUS = 1;
// 维护一个固定大小的视频窗口:窗口内才 mount 真实 <video> 壳。
// 当前屏先绑定 src后续预加载要等当前屏缓冲健康后才开始
// 窗口内只要已经产生过可复用缓冲,就保留 src 复用浏览器缓
const VIDEO_WINDOW_SIZE = 6;
function loadSeenIds(): string[] {
try {
@@ -129,7 +132,6 @@ export default function ShortsPage() {
// seenIds 用 ref 维护,方便在异步 callback 里读到最新值
const seenIdsRef = useRef<string[]>(loadSeenIds());
const preferredFromVideoIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// 整个页面根元素,用于 requestFullscreen
@@ -143,6 +145,7 @@ export default function ShortsPage() {
const [cacheableSourceIds, setCacheableSourceIds] = useState<Set<string>>(
() => new Set()
);
const [cacheWindowHighIndex, setCacheWindowHighIndex] = useState(-1);
// 当前是否处在浏览器全屏(Fullscreen API)状态。
// iOS Safari 不支持元素级 Fullscreen API,这里会一直保持 false
@@ -172,7 +175,7 @@ export default function ShortsPage() {
}
}, []);
// 标记某条视频"浏览器里已有可复用的缓冲"。之后只要它还在相邻屏内,
// 标记某条视频"浏览器里已有可复用的缓冲"。之后只要它还在缓存窗口内,
// 就保留 src 不剥离,回滑/再前滑时直接续用已缓冲数据,秒开不卡顿。
const handleSourceCached = useCallback((videoId: string) => {
setCacheableSourceIds((prev) => {
@@ -207,11 +210,6 @@ export default function ShortsPage() {
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { likes?: number };
if (liked) {
preferredFromVideoIdRef.current = videoId;
} else if (preferredFromVideoIdRef.current === videoId) {
preferredFromVideoIdRef.current = null;
}
return typeof data.likes === "number" ? data.likes : null;
} catch {
// 请求失败:回滚集合,让 Slide 自己回滚 UI
@@ -240,11 +238,7 @@ export default function ShortsPage() {
setLoading(true);
try {
const seen = seenIdsRef.current;
const resp = await fetchShortsNext(
seen,
BATCH_SIZE,
preferredFromVideoIdRef.current ?? undefined
);
const resp = await fetchShortsNext(seen, BATCH_SIZE);
if (resp.items.length === 0) {
setEmpty((prev) => prev || true /* 维持 true 即可 */);
setRoundComplete(true);
@@ -278,6 +272,8 @@ export default function ShortsPage() {
const active = items[activeIndex];
if (!active) return;
setCacheWindowHighIndex((prev) => Math.max(prev, activeIndex));
if (!seenIdsRef.current.includes(active.id)) {
seenIdsRef.current = [...seenIdsRef.current, active.id];
saveSeenIds(seenIdsRef.current);
@@ -286,8 +282,10 @@ export default function ShortsPage() {
const remaining = items.length - 1 - activeIndex;
if (remaining < PREFETCH_THRESHOLD && !loading) {
if (roundComplete) {
// 上一次后端说"本轮已耗尽",且当前已经看到队列接近末尾。
// 清空 localStorage 后再请求即可开新一轮。
// 上一次后端说"本轮已耗尽"时,必须等用户真正滑到当前队列最后一条
// 清空已看记录开新一轮。否则退出后重新进入会把未完成轮次提前重置,
// 导致刚刷过的视频再次出现在下一次会话里。
if (remaining > 0) return;
seenIdsRef.current = [];
saveSeenIds([]);
setRoundComplete(false);
@@ -340,21 +338,10 @@ export default function ShortsPage() {
video.muted = muted;
video.volume = volume;
if (video.paused) {
// 切到这个视频时从头开始播
try {
video.currentTime = 0;
} catch {
// ignore
}
video.play().catch(() => undefined);
}
} else {
if (!video.paused) video.pause();
try {
video.currentTime = 0;
} catch {
// ignore
}
}
});
}, [activeIndex, muted, volume, items.length]);
@@ -634,6 +621,8 @@ export default function ShortsPage() {
}
}, [items.length, showHud]);
const videoWindow = getVideoWindowBounds(cacheWindowHighIndex, items.length);
return (
<div className="shorts-page" ref={pageRef}>
<header className="shorts-header">
@@ -694,12 +683,18 @@ export default function ShortsPage() {
{items.map((item, index) => {
const isActiveSlide = index === activeIndex;
const shouldMount = Math.abs(index - activeIndex) <= MOUNT_RADIUS;
const shouldPreload = activeReadyForPreload && index === activeIndex + 1;
// 相邻屏内已经缓冲过的视频保留 src:
// 回滑上一条、或回滑后再滑回来,都直接复用浏览器已缓冲数据。
const isInCacheWindow =
index >= videoWindow.start && index <= videoWindow.end;
const preloadOffset = index - activeIndex;
const shouldPreload =
activeReadyForPreload &&
preloadOffset > 0 &&
preloadOffset <= PRELOAD_AHEAD_COUNT;
const shouldMount = isActiveSlide || isInCacheWindow || shouldPreload;
// 视频窗口内已经缓冲过的视频保留 src:
// 在窗口内来回切换时,直接复用浏览器已缓冲数据。
const shouldRetainCached =
shouldMount && !isActiveSlide && cacheableSourceIds.has(item.id);
isInCacheWindow && !isActiveSlide && cacheableSourceIds.has(item.id);
const shouldLoad = isActiveSlide || shouldPreload || shouldRetainCached;
const shouldEagerLoad = isActiveSlide || shouldPreload;
return (
@@ -708,9 +703,9 @@ export default function ShortsPage() {
item={item}
index={index}
isActive={isActiveSlide}
// 距离 active 在 MOUNT_RADIUS 之内才挂载 <video> 壳;
// 当前屏先绑定 src下一屏等当前屏缓冲健康后再预加载;
// 已缓冲过的相邻屏保留 src,便于回滑复用缓存。
// 固定 6 条视频窗口内才挂载 <video> 壳;
// 当前屏先绑定 src后两个视频等当前屏缓冲健康后再预加载;
// 已缓冲过的窗口内视频保留 src,便于来回切换复用缓存。
shouldMount={shouldMount}
shouldLoad={shouldLoad}
shouldEagerLoad={shouldEagerLoad}
@@ -763,7 +758,7 @@ type SlideProps = {
onHideSuccess: (index: number) => void;
onActiveReadyForPreload: (index: number) => void;
onActiveNeedsPriority: (index: number) => void;
/** 本条视频在浏览器里已有可复用缓冲,之后在相邻屏内保留 src */
/** 本条视频在浏览器里已有可复用缓冲,之后在视频窗口内保留 src */
onSourceCached: (videoId: string) => void;
showHud: (text: string, icon?: React.ReactNode) => void;
};
@@ -846,7 +841,7 @@ function ShortsSlide({
[videoRef]
);
// 非当前屏/下一屏/缓存上一屏不保留媒体源,确保离开窗口后浏览器中止原始网盘流。
// 非当前屏/后续预加载/视频窗口内缓存视频不保留媒体源,确保离开窗口后浏览器中止原始网盘流。
useEffect(() => {
if (shouldLoad) return;
const video = localRef.current;
@@ -895,8 +890,8 @@ function ShortsSlide({
}, [isMarkedHidden]);
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化。
// MOUNT_RADIUS 会让远离当前屏的 slide 先以海报占位,之后才挂载 video 壳;
// 只有 shouldLoad=true 的当前屏/下一屏会绑定 src,因此不会一次拉完整队列。
// VIDEO_WINDOW_SIZE 会让窗口外的 slide 先以海报占位,之后才挂载 video 壳;
// 只有 shouldLoad=true 的当前屏/后续预加载/缓存窗口视频会绑定 src,因此不会一次拉完整队列。
// 因此这里必须跟随 shouldMount 重新绑定,否则后续视频没有 timeupdate 事件。
useEffect(() => {
if (!shouldMount) {
@@ -926,16 +921,15 @@ function ShortsSlide({
};
const handlePlayingOrCanPlay = () => {
setIsBuffering(false);
// 已经能解码播放,说明浏览器里有了值得复用的数据
// 正在观看的视频从此回滑可秒开。
if (isActive) onSourceCached(item.id);
// 已经能解码播放,说明浏览器里有了值得复用的数据
if (shouldLoad) onSourceCached(item.id);
syncActivePreloadReadiness(video);
};
const handleProgress = () => {
syncActivePreloadReadiness(video);
// 预加载中的下一条积累到足够缓冲后也记为可复用
// 窗口内视频只要已经产生缓冲,就标记为可复用
// 之后预加载授权被收回时不再丢弃它的 src 和已缓冲数据。
if (!isActive && shouldLoad && videoHasComfortableBuffer(video)) {
if (shouldLoad && videoHasBufferedData(video)) {
onSourceCached(item.id);
}
};
@@ -1461,6 +1455,15 @@ function clamp(n: number, min: number, max: number) {
return n < min ? min : n > max ? max : n;
}
function getVideoWindowBounds(highestViewedIndex: number, itemCount: number) {
const size = Math.min(VIDEO_WINDOW_SIZE, itemCount);
if (size <= 0 || highestViewedIndex < 0) return { start: 0, end: -1 };
const end = clamp(highestViewedIndex, 0, itemCount - 1);
const start = Math.max(0, end - size + 1);
return { start, end };
}
/** 已经缓冲到片尾(含误差余量),不会再因网络卡顿 */
function videoBufferedToEnd(video: HTMLVideoElement) {
const duration = Number.isFinite(video.duration) ? video.duration : 0;
@@ -1469,7 +1472,16 @@ function videoBufferedToEnd(video: HTMLVideoElement) {
return bufferedAheadSeconds(video) >= remaining - 0.25;
}
/** 前向缓冲健康(达到高水位或已缓冲到结尾),可以放心预加载下一条 */
function videoHasBufferedData(video: HTMLVideoElement) {
for (let i = 0; i < video.buffered.length; i += 1) {
if (video.buffered.end(i) > video.buffered.start(i)) {
return true;
}
}
return false;
}
/** 前向缓冲健康(达到高水位或已缓冲到结尾),可以放心预加载后续视频 */
function videoHasComfortableBuffer(video: HTMLVideoElement) {
if (video.readyState < 3) return false;
if (videoBufferedToEnd(video)) return true;
+50 -15
View File
@@ -6,20 +6,24 @@ const shortsPageSource = readFileSync(
new URL("../src/pages/ShortsPage.tsx", import.meta.url),
"utf8"
);
const videosDataSource = readFileSync(
new URL("../src/data/videos.ts", import.meta.url),
"utf8"
);
test("shorts recommendation preference follows successful likes instead of watch time", () => {
test("shorts does not keep recommendation preference from likes or watch time", () => {
assert.doesNotMatch(shortsPageSource, /currentTime\s*>=\s*3/);
assert.doesNotMatch(shortsPageSource, /onPreferenceReady/);
assert.doesNotMatch(shortsPageSource, /preferredFromVideoId/);
assert.doesNotMatch(videosDataSource, /preferredFromVideoId/);
const match = /const handleLikeToggle[\s\S]*?const hasLiked/.exec(
shortsPageSource
);
assert.ok(match, "handleLikeToggle block should be present");
assert.match(
match[0],
/if \(liked\) \{\s*preferredFromVideoIdRef\.current = videoId;\s*\} else if \(preferredFromVideoIdRef\.current === videoId\) \{\s*preferredFromVideoIdRef\.current = null;/
);
assert.doesNotMatch(match[0], /preferred/i);
assert.match(videosDataSource, /body: JSON\.stringify\(\{ seenIds, count \}\)/);
});
test("shorts progress dragging uses immediate pointer state", () => {
@@ -34,7 +38,7 @@ test("shorts progress dragging uses immediate pointer state", () => {
test("shorts progress listeners rebind when deferred videos mount", () => {
assert.match(
shortsPageSource,
/MOUNT_RADIUS 会让远离当前屏的 slide 先以海报占位/
/VIDEO_WINDOW_SIZE 会让窗口外的 slide 先以海报占位/
);
assert.match(shortsPageSource, /if \(!shouldMount\) \{\s*setDuration\(0\);\s*setCurrentTime\(0\);/);
assert.match(
@@ -43,10 +47,14 @@ test("shorts progress listeners rebind when deferred videos mount", () => {
);
});
test("shorts preloads the next original video only after the active video has comfortable buffer", () => {
test("shorts preloads the next two original videos only after the active video has comfortable buffer", () => {
assert.match(shortsPageSource, /const \[activeReadyForPreload, setActiveReadyForPreload\] = useState\(false\);/);
assert.match(shortsPageSource, /const ACTIVE_PRELOAD_BUFFER_SECONDS = 12;/);
assert.match(shortsPageSource, /const shouldPreload = activeReadyForPreload && index === activeIndex \+ 1;/);
assert.match(shortsPageSource, /const PRELOAD_AHEAD_COUNT = 2;/);
assert.match(
shortsPageSource,
/const preloadOffset = index - activeIndex;[\s\S]*?preloadOffset > 0 &&[\s\S]*?preloadOffset <= PRELOAD_AHEAD_COUNT;/
);
assert.match(shortsPageSource, /const shouldLoad = isActiveSlide \|\| shouldPreload \|\| shouldRetainCached;/);
assert.match(shortsPageSource, /shouldLoad=\{shouldLoad\}/);
assert.match(shortsPageSource, /setActiveReadyForPreload\(false\);\s*setActiveIndex\(bestIndex\);/);
@@ -79,24 +87,51 @@ test("shorts preload grant uses high/low watermark hysteresis", () => {
);
});
test("shorts keeps adjacent buffered sources as a lightweight cache", () => {
test("shorts waits for the queue end before starting a new seen round", () => {
assert.match(
shortsPageSource,
/if \(roundComplete\) \{[\s\S]*?if \(remaining > 0\) return;[\s\S]*?seenIdsRef\.current = \[\];[\s\S]*?saveSeenIds\(\[\]\);/
);
});
test("shorts keeps buffered sources inside a six video window", () => {
assert.match(shortsPageSource, /const \[cacheableSourceIds, setCacheableSourceIds\] = useState<Set<string>>/);
assert.match(shortsPageSource, /setCacheableSourceIds\(\(prev\) => \{/);
// 相邻屏内(前一条或后一条)已缓冲过的视频都保留 src,回滑/再前滑均复用缓存
assert.match(shortsPageSource, /const VIDEO_WINDOW_SIZE = 6;/);
assert.doesNotMatch(shortsPageSource, /VIDEO_WINDOW_BACKWARD_BIAS/);
assert.match(shortsPageSource, /const \[cacheWindowHighIndex, setCacheWindowHighIndex\] = useState\(-1\);/);
assert.match(shortsPageSource, /setCacheWindowHighIndex\(\(prev\) => Math\.max\(prev, activeIndex\)\);/);
assert.match(shortsPageSource, /function getVideoWindowBounds\(highestViewedIndex: number, itemCount: number\)/);
assert.match(
shortsPageSource,
/const shouldRetainCached =\s*shouldMount && !isActiveSlide && cacheableSourceIds\.has\(item\.id\);/
/const videoWindow = getVideoWindowBounds\(cacheWindowHighIndex, items\.length\);/
);
// 活跃视频一旦 canplay 就标记可复用,快速划走的视频回滑也有缓存
assert.match(
shortsPageSource,
/if \(isActive\) onSourceCached\(item\.id\);/
/const isInCacheWindow =\s*index >= videoWindow\.start && index <= videoWindow\.end;/
);
// 预加载中的下一条积累到足够缓冲后同样标记,授权收回时不丢弃其数据
assert.match(
shortsPageSource,
/if \(!isActive && shouldLoad && videoHasComfortableBuffer\(video\)\) \{\s*onSourceCached\(item\.id\);/
/const shouldMount = isActiveSlide \|\| isInCacheWindow \|\| shouldPreload;/
);
// 视频窗口内已缓冲过的视频都保留 src,来回切换均复用缓存
assert.match(
shortsPageSource,
/const shouldRetainCached =\s*isInCacheWindow && !isActiveSlide && cacheableSourceIds\.has\(item\.id\);/
);
// 窗口内视频一旦 canplay 就标记可复用,快速划走的视频回滑也有缓存
assert.match(
shortsPageSource,
/if \(shouldLoad\) onSourceCached\(item\.id\);/
);
// 窗口内视频只要已经产生缓冲就同样标记,授权收回时不丢弃其数据
assert.match(
shortsPageSource,
/if \(shouldLoad && videoHasBufferedData\(video\)\) \{\s*onSourceCached\(item\.id\);/
);
const playbackBlock = /\/\/ 控制每个 video 的播放状态与音量[\s\S]*?\}, \[activeIndex, muted, volume, items\.length\]\);/.exec(shortsPageSource);
assert.ok(playbackBlock, "parent playback effect should be present");
assert.doesNotMatch(playbackBlock[0], /currentTime\s*=\s*0/);
assert.match(shortsPageSource, /shouldEagerLoad=\{shouldEagerLoad\}/);
assert.match(shortsPageSource, /preload=\{shouldLoad \? \(shouldEagerLoad \? "auto" : "metadata"\) : "none"\}/);
});