mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
Fix teaser playback cache handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user