Fix teaser playback cache handling

This commit is contained in:
Codex
2026-05-16 00:31:52 +08:00
parent 23ba704d35
commit 35387fe7d3
6 changed files with 206 additions and 16 deletions
+12 -1
View File
@@ -548,6 +548,9 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid local path", http.StatusForbidden)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
s.Proxy.ServeLocal(w, r, v.PreviewLocal)
return
}
@@ -588,7 +591,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
Href: "/video/" + v.ID,
Title: v.Title,
Thumbnail: thumbnailURL(v),
PreviewSrc: "/p/preview/" + v.ID,
PreviewSrc: previewURL(v),
PreviewDuration: 12,
PreviewStrategy: "teaser-file",
Duration: formatDuration(v.DurationSeconds),
@@ -606,6 +609,14 @@ func mapVideo(v *catalog.Video) VideoDTO {
}
}
func previewURL(v *catalog.Video) string {
base := "/p/preview/" + v.ID
if v.UpdatedAt.IsZero() {
return base
}
return base + "?v=" + strconv.FormatInt(v.UpdatedAt.UnixMilli(), 10)
}
func thumbnailURL(v *catalog.Video) string {
if v.ThumbnailURL != "" {
return v.ThumbnailURL
+22
View File
@@ -64,6 +64,25 @@ func TestVideoSourceUsesLocalUploadRoute(t *testing.T) {
}
}
func TestPreviewURLIncludesUpdatedAtVersion(t *testing.T) {
got := previewURL(&catalog.Video{
ID: "video-1",
UpdatedAt: time.UnixMilli(1778863000123),
})
if got != "/p/preview/video-1?v=1778863000123" {
t.Fatalf("preview URL = %q, want versioned URL", got)
}
}
func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
got := previewURL(&catalog.Video{ID: "video-1"})
if got != "/p/preview/video-1" {
t.Fatalf("preview URL = %q, want unversioned URL", got)
}
}
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -278,6 +297,9 @@ func TestHandlePreviewIgnoresRemotePreviewFileIDAndServesLocalFile(t *testing.T)
if rr.Body.String() != "local teaser" {
t.Fatalf("body = %q, want local teaser bytes", rr.Body.String())
}
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
}
func TestTranscodeStatusReadyWhenCachedFileExists(t *testing.T) {
+85 -1
View File
@@ -2,6 +2,7 @@ package preview
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -92,6 +93,9 @@ func buildTeaserPlan(cfg Config, duration float64) teaserPlan {
}
eachSec := 3.0
if duration > 0 && duration < eachSec {
eachSec = duration
}
return teaserPlan{
starts: pickSegmentStarts(duration, segs, eachSec),
@@ -102,7 +106,7 @@ func buildTeaserPlan(cfg Config, duration float64) teaserPlan {
// pickSegmentStarts 根据视频总时长选出 N 段起点秒数(按时间升序)
//
// 规则:
// - duration < 30s → 最多 3 段;放不下完整 3 秒片段时丢弃对应片
// - duration < 30s → 最多 3 段;不足 3 秒时用完整短视频作为单
// - 30s ≤ duration < 10min → 4 段:前段跳过片头、末段避开片尾
// - duration ≥ 10min → 固定 4 段,按 20% ~ 80% 等距分布
func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 {
@@ -412,6 +416,10 @@ func (g *Generator) generate(ctx context.Context, duration float64, linkForInput
os.Remove(tmpPath)
return "", fmt.Errorf("ffmpeg produced empty file, stderr: %s", string(out))
}
if err := g.validateGeneratedTeaser(ctx2, tmpPath); err != nil {
os.Remove(tmpPath)
return "", err
}
return tmpPath, nil
}
@@ -499,6 +507,10 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li
_ = os.Remove(tmpPath)
return "", fmt.Errorf("ffmpeg concat produced empty file, stderr: %s", string(out))
}
if err := g.validateGeneratedTeaser(ctx2, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
for _, p := range segmentPaths {
_ = os.Remove(p)
@@ -558,9 +570,81 @@ func (g *Generator) generateSingleSegment(ctx context.Context, index int, start,
_ = os.Remove(segPath)
return "", fmt.Errorf("ffmpeg segment produced empty file, stderr: %s", string(out))
}
if err := g.validateGeneratedTeaser(ctx, segPath); err != nil {
_ = os.Remove(segPath)
return "", err
}
return segPath, nil
}
type localMediaProbe struct {
Streams []struct {
CodecType string `json:"codec_type"`
Duration string `json:"duration"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
func (g *Generator) validateGeneratedTeaser(ctx context.Context, path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() == 0 {
return errors.New("generated teaser is empty")
}
ctx2, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
args := []string{
"-v", "error",
"-show_entries", "stream=codec_type,duration:format=duration",
"-of", "json",
path,
}
out, err := exec.CommandContext(ctx2, g.cfg.FFprobePath, args...).CombinedOutput()
if err != nil {
return ffmpegCommandError("ffprobe teaser", err, out)
}
var probe localMediaProbe
if err := json.Unmarshal(out, &probe); err != nil {
return fmt.Errorf("ffprobe teaser output: %w", err)
}
duration := parseProbeDuration(probe.Format.Duration)
hasVideo := false
for _, stream := range probe.Streams {
if stream.CodecType == "video" {
hasVideo = true
}
if d := parseProbeDuration(stream.Duration); d > duration {
duration = d
}
}
if !hasVideo {
return errors.New("generated teaser has no video stream")
}
if duration <= 0.01 {
return fmt.Errorf("generated teaser has invalid duration %.3fs", duration)
}
return nil
}
func parseProbeDuration(raw string) float64 {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "N/A" {
return 0
}
d, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0
}
return d
}
func escapeConcatPath(path string) string {
if abs, err := filepath.Abs(path); err == nil {
path = abs
+8 -5
View File
@@ -81,13 +81,16 @@ func TestShortVideoPreviewPlanDropsSegmentsThatDoNotFit(t *testing.T) {
}
}
func TestShortVideoPreviewPlanReturnsNoSegmentsWhenOneSegmentCannotFit(t *testing.T) {
func TestTinyVideoPreviewPlanUsesWholeVideoAsSingleSegment(t *testing.T) {
plan := buildTeaserPlan(Config{DurationSeconds: 15, Segments: 3}, 2.5)
if len(plan.starts) != 0 {
t.Fatalf("segments = %d, want 0", len(plan.starts))
if len(plan.starts) != 1 {
t.Fatalf("segments = %d, want 1", len(plan.starts))
}
if plan.eachSec != 3 {
t.Fatalf("eachSec = %.2f, want 3", plan.eachSec)
if plan.eachSec != 2.5 {
t.Fatalf("eachSec = %.2f, want 2.5", plan.eachSec)
}
if plan.starts[0] != 0 {
t.Fatalf("start[0] = %.2f, want 0", plan.starts[0])
}
}
+9 -9
View File
@@ -161,7 +161,7 @@ export function DrivesPage() {
/ 115 / PikPak / / OneDrive
</div>
) : (
<table className="admin-table">
<table className="admin-table admin-drives-table">
<thead>
<tr>
<th></th>
@@ -177,22 +177,22 @@ export function DrivesPage() {
<tbody>
{list.map((d) => (
<tr key={d.id}>
<td>{d.name || <span style={{ color: "#aaa" }}></span>}</td>
<td>{kindLabel[d.kind] ?? d.kind}</td>
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{d.id}</td>
<td>
<td data-label="名称">{d.name || <span style={{ color: "#aaa" }}></span>}</td>
<td data-label="类型">{kindLabel[d.kind] ?? d.kind}</td>
<td data-label="ID" style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{d.id}</td>
<td data-label="状态">
<StatusTag status={d.status} error={d.lastError} hasCred={d.hasCredential} />
</td>
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
<td data-label="扫描根" style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
{d.scanRootId || d.rootId}
</td>
<td>
<td data-label="本地占用">
<StorageCell usage={storage?.drives[d.id]} />
</td>
<td>
<td data-label="Teaser">
<TeaserCounts drive={d} />
</td>
<td className="is-actions">
<td className="is-actions" data-label="操作">
<button className="admin-btn" onClick={() => handleRescan(d)}>
<RefreshCw size={13} />
</button>{" "}
+70
View File
@@ -347,6 +347,76 @@
white-space: nowrap;
}
@media (max-width: 1200px) {
.admin-table.admin-drives-table {
display: block;
max-width: 100%;
overflow: visible;
}
.admin-table.admin-drives-table thead {
display: none;
}
.admin-table.admin-drives-table tbody {
display: grid;
gap: 10px;
}
.admin-table.admin-drives-table tr {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 14px;
padding: 12px;
border-bottom: 1px solid var(--color-line);
}
.admin-table.admin-drives-table tr:last-child {
border-bottom: 0;
}
.admin-table.admin-drives-table td {
display: grid;
align-content: start;
gap: 4px;
min-width: 0;
padding: 0;
border-bottom: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.admin-table.admin-drives-table td::before {
content: attr(data-label);
color: var(--color-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.admin-table.admin-drives-table td.is-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-start;
min-width: 0;
text-align: left;
white-space: normal;
}
.admin-table.admin-drives-table td.is-actions::before {
flex-basis: 100%;
}
.admin-table.admin-drives-table .admin-status,
.admin-table.admin-drives-table .admin-storage-cell,
.admin-table.admin-drives-table .admin-teaser-counts {
justify-self: start;
}
}
.admin-status {
display: inline-flex;
align-items: center;