feat: add upload flow and drive maintenance tools

This commit is contained in:
nianzhibai
2026-05-12 14:16:42 +08:00
parent 7fdb6a0a78
commit 31a2f99feb
27 changed files with 1913 additions and 73 deletions
+138 -6
View File
@@ -21,6 +21,7 @@ import (
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/config"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/drives/localupload"
"github.com/video-site/backend/internal/drives/onedrive"
"github.com/video-site/backend/internal/drives/p115"
"github.com/video-site/backend/internal/drives/pikpak"
@@ -68,6 +69,9 @@ func main() {
defer cancel()
app.loadPreviewEnabled(ctx)
if err := app.attachLocalUpload(ctx); err != nil {
log.Printf("[local-upload] attach failed: %v", err)
}
existing, err := cat.ListDrives(ctx)
if err != nil {
@@ -89,7 +93,11 @@ func main() {
Catalog: cat,
Proxy: app.proxy,
LocalDir: cfg.Storage.LocalPreviewDir,
UploadDir: app.localUploadDir(),
FFmpegPath: cfg.Preview.FFmpegPath,
OnVideoUploaded: func(v *catalog.Video) {
app.enqueueUploadedVideo(ctx, v)
},
}
adminServer := &api.AdminServer{
@@ -114,6 +122,9 @@ func main() {
OnRegenAllPreviews: func() {
go app.regenAllPreviews(ctx)
},
OnRegenFailedPreviews: func(driveID string) {
go app.regenFailedPreviews(ctx, driveID)
},
GetPreviewEnabled: func() bool { return app.PreviewEnabled() },
SetPreviewEnabled: func(enabled bool) error {
return app.SetPreviewEnabled(ctx, enabled)
@@ -335,6 +346,37 @@ func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
return nil
}
func (a *App) attachLocalUpload(ctx context.Context) error {
drv := localupload.New(a.localUploadDir())
if err := drv.Init(ctx); err != nil {
return err
}
a.registry.Set(drv.ID(), drv)
gen := preview.New(preview.Config{
FFmpegPath: a.cfg.Preview.FFmpegPath,
FFprobePath: a.cfg.Preview.FFprobePath,
DurationSeconds: a.cfg.Preview.DurationSeconds,
Width: a.cfg.Preview.Width,
Segments: a.cfg.Preview.Segments,
LocalDir: a.cfg.Storage.LocalPreviewDir,
RemoteDir: "",
})
worker := preview.NewWorker(gen, a.cat, drv, "")
thumbWorker := preview.NewThumbWorker(gen, a.cat, drv)
workerCtx, cancel := context.WithCancel(ctx)
go worker.Run(workerCtx)
go thumbWorker.Run(workerCtx)
a.registerPreviewWorkers(ctx, drv.ID(), worker, thumbWorker, cancel)
return nil
}
func (a *App) localUploadDir() string {
return filepath.Join(filepath.Dir(a.cfg.Storage.LocalPreviewDir), "uploads")
}
func (a *App) registerPreviewWorkers(ctx context.Context, driveID string, worker *preview.Worker, thumbWorker *preview.ThumbWorker, cancel context.CancelFunc) {
a.mu.Lock()
if a.cancels == nil {
@@ -472,6 +514,24 @@ func (a *App) runScan(ctx context.Context, driveID string) {
}
}
func (a *App) enqueueUploadedVideo(ctx context.Context, v *catalog.Video) {
if v == nil {
return
}
a.mu.Lock()
worker := a.workers[v.DriveID]
thumbWorker := a.thumbWorkers[v.DriveID]
previewEnabled := a.previewEnabled
a.mu.Unlock()
if thumbWorker != nil && v.ThumbnailURL == "" {
thumbWorker.Enqueue(v)
}
if previewEnabled && worker != nil {
worker.Enqueue(v)
}
}
func (a *App) regenPreview(ctx context.Context, videoID string) {
v, err := a.cat.GetVideo(ctx, videoID)
if err != nil {
@@ -513,31 +573,103 @@ func (a *App) regenAllPreviews(ctx context.Context) {
log.Printf("[preview] enqueued all visible videos for regen queued=%d", queued)
}
func (a *App) scanLoop(ctx context.Context) {
// 启动后立刻扫一次
a.scanAllOnce(ctx)
func (a *App) regenFailedPreviews(ctx context.Context, driveID string) {
items, err := a.cat.ListVideosByPreviewStatus(ctx, driveID, "failed", 0)
if err != nil {
log.Printf("[preview] list failed videos for regen drive=%s: %v", driveID, err)
return
}
a.mu.Lock()
worker := a.workers[driveID]
a.mu.Unlock()
if worker == nil {
log.Printf("[preview] regen failed drive=%s skipped: worker not found", driveID)
return
}
log.Printf("[preview] enqueue failed videos for regen drive=%s count=%d", driveID, len(items))
queued := 0
for _, v := range items {
if err := ctx.Err(); err != nil {
log.Printf("[preview] enqueue failed canceled drive=%s queued=%d: %v", driveID, queued, err)
return
}
if err := a.cat.UpdatePreview(ctx, v.ID, "", "", "pending"); err != nil {
log.Printf("[preview] reset failed video %s drive=%s: %v", v.ID, driveID, err)
continue
}
v.PreviewFileID = ""
v.PreviewLocal = ""
v.PreviewStatus = "pending"
if !worker.EnqueueBlocking(ctx, v) {
log.Printf("[preview] enqueue failed canceled drive=%s queued=%d", driveID, queued)
return
}
queued++
}
log.Printf("[preview] enqueued failed videos for regen drive=%s queued=%d", driveID, queued)
}
func (a *App) scanLoop(ctx context.Context) {
if a.cfg.Scanner.IntervalSeconds <= 0 {
return
}
ticker := time.NewTicker(time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second)
interval := time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second
var lastScheduledScan time.Time
if a.scanAllOnceIfDue(ctx, time.Now(), lastScheduledScan, interval) {
lastScheduledScan = time.Now()
}
checkEvery := interval
if checkEvery > time.Minute {
checkEvery = time.Minute
}
ticker := time.NewTicker(checkEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.scanAllOnce(ctx)
case now := <-ticker.C:
if a.scanAllOnceIfDue(ctx, now, lastScheduledScan, interval) {
lastScheduledScan = now
}
}
}
}
func (a *App) scanAllOnceIfDue(ctx context.Context, now, lastScheduledScan time.Time, interval time.Duration) bool {
if !scheduledScanDue(now, lastScheduledScan, interval) {
return false
}
a.scanAllOnce(ctx)
return true
}
func scheduledScanDue(now, lastScheduledScan time.Time, interval time.Duration) bool {
if interval <= 0 || !scheduledScanAllowed(now) {
return false
}
return lastScheduledScan.IsZero() || now.Sub(lastScheduledScan) >= interval
}
func scheduledScanAllowed(now time.Time) bool {
hour := now.Hour()
return hour >= 2 && hour < 7
}
func (a *App) scanAllOnce(ctx context.Context) {
for _, d := range a.registry.All() {
if !shouldScanDrive(d) {
continue
}
a.runScan(ctx, d.ID())
}
}
func shouldScanDrive(d drives.Drive) bool {
return d != nil && d.ID() != localupload.DriveID
}
// ---------- middleware ----------
func corsMiddleware(next http.Handler) http.Handler {
+199
View File
@@ -72,6 +72,199 @@ func TestRegisterPreviewWorkerBackfillsPendingWhenPreviewEnabled(t *testing.T) {
t.Fatalf("preview status = %q, want ready", got.PreviewStatus)
}
func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(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()
for _, v := range []*catalog.Video{
{ID: "target-failed", DriveID: "drive-id", FileID: "file-1", Title: "Target Failed", PreviewStatus: "failed"},
{ID: "target-ready", DriveID: "drive-id", FileID: "file-2", Title: "Target Ready", PreviewStatus: "ready", PreviewLocal: "/tmp/ready.mp4"},
{ID: "other-failed", DriveID: "other-drive", FileID: "file-3", Title: "Other Failed", PreviewStatus: "failed"},
} {
v.PublishedAt = now
v.CreatedAt = now
v.UpdatedAt = now
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed video %s: %v", v.ID, err)
}
}
app := &App{
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
worker := preview.NewWorker(&serverFakeTeaserGenerator{}, cat, &serverFakeDrive{}, "")
go worker.Run(ctx)
app.mu.Lock()
app.workers["drive-id"] = worker
app.mu.Unlock()
app.regenFailedPreviews(ctx, "drive-id")
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
got, err := cat.GetVideo(ctx, "target-failed")
if err != nil {
t.Fatalf("get target failed: %v", err)
}
if got.PreviewStatus == "ready" {
if got.PreviewLocal != "/tmp/target-failed.mp4" {
t.Fatalf("target preview local = %q, want regenerated local teaser path", got.PreviewLocal)
}
break
}
time.Sleep(10 * time.Millisecond)
}
target, err := cat.GetVideo(ctx, "target-failed")
if err != nil {
t.Fatalf("get regenerated target: %v", err)
}
if target.PreviewStatus != "ready" {
t.Fatalf("target preview status = %q, want ready", target.PreviewStatus)
}
ready, err := cat.GetVideo(ctx, "target-ready")
if err != nil {
t.Fatalf("get target ready: %v", err)
}
if ready.PreviewLocal != "/tmp/ready.mp4" || ready.PreviewStatus != "ready" {
t.Fatalf("ready video changed: status=%q local=%q", ready.PreviewStatus, ready.PreviewLocal)
}
other, err := cat.GetVideo(ctx, "other-failed")
if err != nil {
t.Fatalf("get other failed: %v", err)
}
if other.PreviewStatus != "failed" {
t.Fatalf("other drive preview status = %q, want failed", other.PreviewStatus)
}
}
func TestEnqueueUploadedVideoQueuesLocalPreviewWorker(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)
}
})
video := &catalog.Video{
ID: "local-upload-video",
DriveID: "local-upload",
FileID: "upload-1.mp4",
Title: "Uploaded",
PreviewStatus: "pending",
PublishedAt: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := cat.UpsertVideo(ctx, video); err != nil {
t.Fatalf("seed video: %v", err)
}
app := &App{
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
worker := preview.NewWorker(&serverFakeTeaserGenerator{}, cat, &serverLocalUploadFakeDrive{}, "")
go worker.Run(ctx)
app.mu.Lock()
app.workers["local-upload"] = worker
app.mu.Unlock()
app.enqueueUploadedVideo(ctx, video)
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" {
if got.PreviewLocal != "/tmp/local-upload-video.mp4" {
t.Fatalf("preview local = %q, want generated local teaser path", got.PreviewLocal)
}
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", got.PreviewStatus)
}
func TestScheduledScanWindowAllowsOnlyEarlyMorning(t *testing.T) {
loc := time.FixedZone("CST", 8*60*60)
cases := []struct {
name string
now time.Time
want bool
}{
{name: "before window", now: time.Date(2026, 5, 12, 1, 59, 0, 0, loc), want: false},
{name: "at start", now: time.Date(2026, 5, 12, 2, 0, 0, 0, loc), want: true},
{name: "inside window", now: time.Date(2026, 5, 12, 6, 59, 0, 0, loc), want: true},
{name: "at end", now: time.Date(2026, 5, 12, 7, 0, 0, 0, loc), want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := scheduledScanAllowed(tc.now); got != tc.want {
t.Fatalf("scheduledScanAllowed(%s) = %v, want %v", tc.now.Format(time.RFC3339), got, tc.want)
}
})
}
}
func TestScheduledScanDueRespectsWindowAndInterval(t *testing.T) {
loc := time.FixedZone("CST", 8*60*60)
interval := 2 * time.Hour
inside := time.Date(2026, 5, 12, 2, 0, 0, 0, loc)
if scheduledScanDue(time.Date(2026, 5, 12, 1, 59, 0, 0, loc), time.Time{}, interval) {
t.Fatal("scheduled scan due outside window, want false")
}
if !scheduledScanDue(inside, time.Time{}, interval) {
t.Fatal("first scheduled scan inside window = false, want true")
}
if scheduledScanDue(inside.Add(time.Hour), inside, interval) {
t.Fatal("scheduled scan due before interval elapsed, want false")
}
if !scheduledScanDue(inside.Add(2*time.Hour), inside, interval) {
t.Fatal("scheduled scan due after interval elapsed, want true")
}
}
func TestShouldScanDriveSkipsLocalUpload(t *testing.T) {
if shouldScanDrive(&serverLocalUploadFakeDrive{}) {
t.Fatal("local upload drive should not be scanned")
}
if !shouldScanDrive(&serverFakeDrive{}) {
t.Fatal("normal drive should be scanned")
}
}
type serverFakeTeaserGenerator struct{}
func (g *serverFakeTeaserGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) {
@@ -109,3 +302,9 @@ func (d *serverFakeDrive) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *serverFakeDrive) RootID() string { return "root" }
type serverLocalUploadFakeDrive struct {
serverFakeDrive
}
func (d *serverLocalUploadFakeDrive) ID() string { return "local-upload" }
+1 -1
View File
@@ -16,7 +16,7 @@ storage:
local_preview_dir: "./data/previews"
scanner:
# 扫描间隔(秒),0 表示只启动时扫一次
# 自动扫盘最小间隔(秒);只在每天 02:00-07:00 触发,0 表示仅允许管理员手动重扫
interval_seconds: 21600
# 单次扫描每家网盘目录递归层数上限
max_depth: 5
+15 -5
View File
@@ -16,11 +16,12 @@ type AdminServer struct {
Catalog *catalog.Catalog
Auth *auth.Authenticator
// Hooks:外层注入实际执行者
OnDriveSaved func(driveID string) error
OnDriveRemoved func(driveID string)
OnScanRequested func(driveID string)
OnRegenPreview func(videoID string)
OnRegenAllPreviews func()
OnDriveSaved func(driveID string) error
OnDriveRemoved func(driveID string)
OnScanRequested func(driveID string)
OnRegenPreview func(videoID string)
OnRegenAllPreviews func()
OnRegenFailedPreviews func(driveID string)
// Preview 开关读写
GetPreviewEnabled func() bool
SetPreviewEnabled func(enabled bool) error
@@ -42,6 +43,7 @@ func (a *AdminServer) Register(r chi.Router) {
r.Post("/drives", a.handleUpsertDrive)
r.Delete("/drives/{id}", a.handleDeleteDrive)
r.Post("/drives/{id}/rescan", a.handleRescan)
r.Post("/drives/{id}/previews/failed/regenerate", a.handleRegenFailedPreviews)
// 视频
r.Get("/videos", a.handleAdminListVideos)
@@ -345,6 +347,14 @@ func (a *AdminServer) handleRegenAllPreviews(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}
func (a *AdminServer) handleRegenFailedPreviews(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if a.OnRegenFailedPreviews != nil {
a.OnRegenFailedPreviews(id)
}
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}
// ---------- Settings ----------
type settingsDTO struct {
+25
View File
@@ -10,6 +10,8 @@ import (
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/video-site/backend/internal/catalog"
)
@@ -374,3 +376,26 @@ func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) {
t.Fatal("regen all previews hook was not called")
}
}
func TestHandleRegenFailedPreviewsInvokesHookWithDriveID(t *testing.T) {
calledWith := ""
server := &AdminServer{
OnRegenFailedPreviews: func(driveID string) {
calledWith = driveID
},
}
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/PikPak/previews/failed/regenerate", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "PikPak")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
server.handleRegenFailedPreviews(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if calledWith != "PikPak" {
t.Fatalf("hook called with %q, want PikPak", calledWith)
}
}
+249 -4
View File
@@ -2,10 +2,13 @@ package api
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -14,19 +17,42 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/video-site/backend/internal/auth"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives/localupload"
"github.com/video-site/backend/internal/proxy"
)
const localUploadDriveID = localupload.DriveID
var allowedUploadExtensions = map[string]struct{}{
".avi": {},
".mkv": {},
".mov": {},
".mp4": {},
".webm": {},
}
var allowedUploadTags = map[string]struct{}{
"奶子": {},
"臀": {},
"口角": {},
"女大": {},
"人妻": {},
"AV": {},
}
type Server struct {
Catalog *catalog.Catalog
Proxy *proxy.Proxy
LocalDir string
FFmpegPath string
Catalog *catalog.Catalog
Proxy *proxy.Proxy
LocalDir string
UploadDir string
FFmpegPath string
OnVideoUploaded func(*catalog.Video)
transcodeMu sync.Mutex
transcodeJobs map[string]bool
@@ -93,10 +119,12 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
r.Put("/api/video/{id}/tags", s.handleUpdateVideoTags)
r.Post("/api/video/{id}/like", s.handleLike)
r.Post("/api/video/{id}/hide", s.handleHideVideo)
r.Post("/api/upload", s.handleUploadVideo)
r.Get("/api/tags", s.handleTags)
// 代理路由同样需要鉴权,防止绕过
r.Get("/p/stream/{driveID}/{fileID}", s.handleStream)
r.Get("/p/upload/{videoID}", s.handleUploadedVideo)
r.Get("/p/transcode/{videoID}/status", s.handleTranscodeStatus)
r.Post("/p/transcode/{videoID}/start", s.handleTranscodeStart)
r.Get("/p/transcode/{videoID}", s.handleTranscode)
@@ -249,12 +277,137 @@ func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleUploadVideo(w http.ResponseWriter, r *http.Request) {
if s.LocalDir == "" {
writeErr(w, http.StatusInternalServerError, errors.New("local storage is not configured"))
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
file, header, err := r.FormFile("file")
if err != nil {
writeErr(w, http.StatusBadRequest, errors.New("video file is required"))
return
}
defer file.Close()
originalName := filepath.Base(strings.TrimSpace(header.Filename))
ext := strings.ToLower(filepath.Ext(originalName))
if _, ok := allowedUploadExtensions[ext]; !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("unsupported video extension: %s", ext))
return
}
tags, err := parseUploadTags(uploadTagValues(r))
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
now := time.Now()
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
title = "upload-" + now.Format("20060102150405")
}
uploadID, err := newUploadID(now)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
storedName := uploadID + ext
dst, err := s.localUploadFilePath(storedName)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
size, copyErr := io.Copy(out, file)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(dst)
writeErr(w, http.StatusInternalServerError, copyErr)
return
}
if closeErr != nil {
_ = os.Remove(dst)
writeErr(w, http.StatusInternalServerError, closeErr)
return
}
if size <= 0 {
_ = os.Remove(dst)
writeErr(w, http.StatusBadRequest, errors.New("uploaded video is empty"))
return
}
video := &catalog.Video{
ID: localUploadDriveID + "-" + uploadID,
DriveID: localUploadDriveID,
FileID: storedName,
FileName: originalName,
Title: title,
Author: "用户上传",
Tags: tags,
Size: size,
Ext: strings.TrimPrefix(ext, "."),
PreviewStatus: "pending",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.Catalog.UpsertVideo(r.Context(), video); err != nil {
_ = os.Remove(dst)
writeErr(w, http.StatusInternalServerError, err)
return
}
if s.OnVideoUploaded != nil {
s.OnVideoUploaded(video)
}
writeJSON(w, http.StatusCreated, mapVideo(video))
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
driveID := chi.URLParam(r, "driveID")
fileID := chi.URLParam(r, "fileID")
s.Proxy.ServeStream(w, r, driveID, fileID)
}
func (s *Server) handleUploadedVideo(w http.ResponseWriter, r *http.Request) {
videoID := chi.URLParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil || v.Hidden || v.DriveID != localUploadDriveID {
http.NotFound(w, r)
return
}
path, err := s.localUploadFilePath(v.FileID)
if err != nil {
http.Error(w, "invalid upload file", http.StatusForbidden)
return
}
info, err := os.Stat(path)
if err != nil || info.IsDir() || info.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "private, max-age=300")
http.ServeFile(w, r, path)
}
func (s *Server) handleTranscode(w http.ResponseWriter, r *http.Request) {
videoID := chi.URLParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
@@ -465,6 +618,12 @@ func thumbnailURL(v *catalog.Video) string {
}
func videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
if needsBrowserTranscode(v.Ext) {
return "/p/transcode/" + v.ID
}
return "/p/upload/" + v.ID
}
if needsBrowserTranscode(v.Ext) {
return "/p/transcode/" + v.ID
}
@@ -546,6 +705,92 @@ func (s *Server) transcodeTempPath(videoID string) string {
return filepath.Join(s.LocalDir, "transcodes", videoID+".tmp.mp4")
}
func (s *Server) localUploadFilePath(fileID string) (string, error) {
if strings.TrimSpace(fileID) == "" || filepath.Base(fileID) != fileID {
return "", errors.New("invalid upload file id")
}
root := s.localUploadDir()
if root == "" {
return "", errors.New("local upload storage is not configured")
}
path := filepath.Join(root, fileID)
cleanRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
cleanPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator)) {
return "", errors.New("invalid upload file id")
}
return cleanPath, nil
}
func (s *Server) localUploadDir() string {
if s.UploadDir != "" {
return s.UploadDir
}
if s.LocalDir == "" {
return ""
}
return filepath.Join(filepath.Dir(s.LocalDir), "uploads")
}
func uploadTagValues(r *http.Request) []string {
if r.MultipartForm == nil {
return nil
}
values := append([]string{}, r.MultipartForm.Value["tags"]...)
values = append(values, r.MultipartForm.Value["tag"]...)
return values
}
func parseUploadTags(values []string) ([]string, error) {
seen := make(map[string]struct{})
out := make([]string, 0, len(values))
for _, value := range values {
for _, label := range splitUploadTags(value) {
if _, ok := allowedUploadTags[label]; !ok {
return nil, fmt.Errorf("unsupported upload tag: %s", label)
}
if _, ok := seen[label]; ok {
continue
}
seen[label] = struct{}{}
out = append(out, label)
}
}
return out, nil
}
func splitUploadTags(value string) []string {
fields := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ',', '', ';', '', '\n', '\r', '\t', ' ':
return true
default:
return false
}
})
out := make([]string, 0, len(fields))
for _, field := range fields {
if label := strings.TrimSpace(field); label != "" {
out = append(out, label)
}
}
return out
}
func newUploadID(now time.Time) (string, error) {
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
return "", err
}
return fmt.Sprintf("upload-%d-%s", now.UnixNano(), hex.EncodeToString(suffix[:])), nil
}
func mapVideos(vs []*catalog.Video) []VideoDTO {
out := make([]VideoDTO, 0, len(vs))
for _, v := range vs {
+230 -1
View File
@@ -1,8 +1,10 @@
package api
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
@@ -46,6 +48,188 @@ func TestVideoSourceKeepsDirectStreamForMp4(t *testing.T) {
}
}
func TestVideoSourceUsesLocalUploadRoute(t *testing.T) {
v := &catalog.Video{
ID: "video-1",
DriveID: localUploadDriveID,
FileID: "upload-1.mp4",
Ext: "mp4",
}
got := videoSource(v)
if got != "/p/upload/video-1" {
t.Fatalf("video source = %q, want local upload route", got)
}
}
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(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)
}
})
var queued *catalog.Video
server := &Server{
Catalog: cat,
LocalDir: t.TempDir(),
OnVideoUploaded: func(v *catalog.Video) {
queued = v
},
}
req := multipartUploadRequest(t, map[string]string{
"title": "用户上传标题",
"tags": "奶子,AV,女大",
}, "clip.mp4", "video-bytes")
rr := httptest.NewRecorder()
server.handleUploadVideo(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var dto VideoDTO
if err := json.NewDecoder(rr.Body).Decode(&dto); err != nil {
t.Fatalf("decode response: %v", err)
}
if dto.ID == "" {
t.Fatal("response video id is empty")
}
got, err := cat.GetVideo(ctx, dto.ID)
if err != nil {
t.Fatalf("get uploaded video: %v", err)
}
if got.DriveID != localUploadDriveID {
t.Fatalf("drive id = %q, want %q", got.DriveID, localUploadDriveID)
}
if got.Title != "用户上传标题" {
t.Fatalf("title = %q, want submitted title", got.Title)
}
if !sameStringSet(got.Tags, []string{"奶子", "AV", "女大"}) {
t.Fatalf("tags = %#v, want selected tags", got.Tags)
}
if got.PreviewStatus != "pending" {
t.Fatalf("preview status = %q, want pending", got.PreviewStatus)
}
path := filepath.Join(server.localUploadDir(), got.FileID)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read uploaded file: %v", err)
}
if string(data) != "video-bytes" {
t.Fatalf("uploaded file content = %q, want original bytes", string(data))
}
if queued == nil || queued.ID != got.ID {
t.Fatalf("queued video = %#v, want uploaded video", queued)
}
}
func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(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)
}
})
server := &Server{Catalog: cat, LocalDir: t.TempDir()}
req := multipartUploadRequest(t, map[string]string{"title": " "}, "clip.mp4", "video-bytes")
rr := httptest.NewRecorder()
server.handleUploadVideo(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var dto VideoDTO
if err := json.NewDecoder(rr.Body).Decode(&dto); err != nil {
t.Fatalf("decode response: %v", err)
}
got, err := cat.GetVideo(ctx, dto.ID)
if err != nil {
t.Fatalf("get uploaded video: %v", err)
}
if got.Title == "" || !strings.HasPrefix(got.Title, "upload-") {
t.Fatalf("title = %q, want upload timestamp fallback", got.Title)
}
}
func TestHandleUploadVideoRejectsUnsupportedTag(t *testing.T) {
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)
}
})
server := &Server{Catalog: cat, LocalDir: t.TempDir()}
req := multipartUploadRequest(t, map[string]string{"tags": "奶子,后入"}, "clip.mp4", "video-bytes")
rr := httptest.NewRecorder()
server.handleUploadVideo(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestHandleUploadedVideoServesLocalUploadFile(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)
}
})
root := t.TempDir()
localDir := filepath.Join(root, "previews")
uploadDir := filepath.Join(root, "uploads")
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
t.Fatalf("mkdir uploads: %v", err)
}
if err := os.WriteFile(filepath.Join(uploadDir, "upload-1.mp4"), []byte("video-bytes"), 0o644); err != nil {
t.Fatalf("write upload: %v", err)
}
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "video-1",
DriveID: localUploadDriveID,
FileID: "upload-1.mp4",
Title: "Uploaded",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
server := &Server{Catalog: cat, LocalDir: localDir}
req := requestWithRouteParam(http.MethodGet, "/p/upload/video-1", "videoID", "video-1", strings.NewReader(``))
rr := httptest.NewRecorder()
server.handleUploadedVideo(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if rr.Body.String() != "video-bytes" {
t.Fatalf("body = %q, want uploaded bytes", rr.Body.String())
}
}
func TestTranscodeStatusReadyWhenCachedFileExists(t *testing.T) {
s := &Server{LocalDir: t.TempDir()}
videoID := "video-1"
@@ -367,10 +551,55 @@ func containsString(list []string, value string) bool {
return false
}
func sameStringSet(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
}
func requestWithVideoID(method, target, videoID string, body *strings.Reader) *http.Request {
return requestWithRouteParam(method, target, "id", videoID, body)
}
func requestWithRouteParam(method, target, key, value string, body *strings.Reader) *http.Request {
req := httptest.NewRequest(method, target, body)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", videoID)
rctx.URLParams.Add(key, value)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
return req
}
func multipartUploadRequest(t *testing.T, fields map[string]string, fileName, fileContent string) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
t.Fatalf("write field %s: %v", key, err)
}
}
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write([]byte(fileContent)); err != nil {
t.Fatalf("write file part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/upload", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
+53 -16
View File
@@ -44,6 +44,7 @@ type Video struct {
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ContentHash string `json:"contentHash"`
ParentID string `json:"parentId"`
Title string `json:"title"`
@@ -84,19 +85,23 @@ func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
_, err := c.db.ExecContext(ctx, `
INSERT INTO videos (
id, drive_id, file_id, content_hash, parent_id, title, author, tags,
id, drive_id, file_id, file_name, content_hash, parent_id, title, author, tags,
duration_seconds, size_bytes, ext, quality, thumbnail_url,
preview_file_id, preview_local, preview_status,
views, favorites, comments, likes, dislikes,
category, hidden, badges, description, published_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
file_name = CASE
WHEN excluded.file_name != '' THEN excluded.file_name
ELSE videos.file_name
END,
title = excluded.title,
author = excluded.author,
tags = excluded.tags,
@@ -114,7 +119,7 @@ ON CONFLICT(id) DO UPDATE SET
description = excluded.description,
updated_at = excluded.updated_at
`,
v.ID, v.DriveID, v.FileID, v.ContentHash, v.ParentID, v.Title, v.Author, string(tagsJSON),
v.ID, v.DriveID, v.FileID, v.FileName, v.ContentHash, v.ParentID, v.Title, v.Author, string(tagsJSON),
v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL,
v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus),
v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes,
@@ -185,6 +190,7 @@ type VideoMetaPatch struct {
DurationSeconds int
Category string
ContentHash string
FileName string
Tags []string
TagsSet bool
}
@@ -208,6 +214,10 @@ func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPat
parts = append(parts, "content_hash = ?")
args = append(args, normalizeContentHash(p.ContentHash))
}
if p.FileName != "" {
parts = append(parts, "file_name = ?")
args = append(args, p.FileName)
}
if p.TagsSet {
tagsJSON, _ := json.Marshal(p.Tags)
parts = append(parts, "tags = ?")
@@ -358,6 +368,19 @@ func (c *Catalog) FindVideoByContentHash(ctx context.Context, hash string) (*Vid
return scanVideo(row)
}
func (c *Catalog) FindVideoByFileSignature(ctx context.Context, fileName string, size int64) (*Video, error) {
if fileName == "" || size <= 0 {
return nil, sql.ErrNoRows
}
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+`
FROM videos
WHERE file_name = ? AND size_bytes = ?
ORDER BY created_at ASC, id ASC
LIMIT 1`, fileName, size)
return scanVideo(row)
}
type ListParams struct {
Keyword string
DriveID string
@@ -612,7 +635,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.upd
// ---------- helpers ----------
const allVideoCols = `
id, drive_id, file_id, COALESCE(content_hash, ''), COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'),
id, drive_id, file_id, COALESCE(file_name, ''), COALESCE(content_hash, ''), COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'),
duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''),
COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'),
views, favorites, comments, likes, dislikes,
@@ -620,17 +643,31 @@ COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(de
published_at, created_at, updated_at
`
const uniqueVideoWhereSQL = `(COALESCE(videos.content_hash, '') = ''
OR NOT EXISTS (
SELECT 1
FROM videos AS dup
WHERE dup.content_hash = videos.content_hash
AND COALESCE(dup.content_hash, '') != ''
AND (
dup.created_at < videos.created_at
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
)
))`
const uniqueVideoWhereSQL = `((COALESCE(videos.content_hash, '') = ''
OR NOT EXISTS (
SELECT 1
FROM videos AS dup
WHERE dup.content_hash = videos.content_hash
AND COALESCE(dup.content_hash, '') != ''
AND (
dup.created_at < videos.created_at
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
)
))
AND (COALESCE(videos.file_name, '') = ''
OR videos.size_bytes <= 0
OR NOT EXISTS (
SELECT 1
FROM videos AS dup
WHERE dup.file_name = videos.file_name
AND dup.size_bytes = videos.size_bytes
AND COALESCE(dup.file_name, '') != ''
AND dup.size_bytes > 0
AND (
dup.created_at < videos.created_at
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
)
)))`
type rowScanner interface {
Scan(dest ...any) error
@@ -642,7 +679,7 @@ func scanVideo(row rowScanner) (*Video, error) {
var publishedAt, createdAt, updatedAt int64
var hidden int
err := row.Scan(
&v.ID, &v.DriveID, &v.FileID, &v.ContentHash, &v.ParentID, &v.Title, &v.Author, &tagsJSON,
&v.ID, &v.DriveID, &v.FileID, &v.FileName, &v.ContentHash, &v.ParentID, &v.Title, &v.Author, &tagsJSON,
&v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL,
&v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus,
&v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes,
+1
View File
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY, -- <drive>-<fileID> 拼接的稳定 ID
drive_id TEXT NOT NULL,
file_id TEXT NOT NULL,
file_name TEXT DEFAULT '', -- 网盘侧原始文件名,用于同名同大小去重
content_hash TEXT DEFAULT '',
parent_id TEXT,
title TEXT NOT NULL,
+38
View File
@@ -42,6 +42,9 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.addColumnIfMissing(ctx, "videos", "content_hash", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := c.addColumnIfMissing(ctx, "videos", "file_name", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := c.addColumnIfMissing(ctx, "videos", "hidden", "INTEGER DEFAULT 0"); err != nil {
return err
}
@@ -51,6 +54,9 @@ func (c *Catalog) migrate(ctx context.Context) error {
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_hidden ON videos(hidden)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_file_name_size ON videos(file_name, size_bytes)`); err != nil {
return err
}
if err := c.seedSystemTags(ctx); err != nil {
return err
}
@@ -63,6 +69,12 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.createCollectionTagsFromCategories(ctx); err != nil {
return err
}
if err := c.clearVolatileOneDriveThumbnails(ctx); err != nil {
return err
}
if err := c.hideZeroSizeVideosFromKnownDrives(ctx); err != nil {
return err
}
return nil
}
@@ -89,6 +101,32 @@ func (c *Catalog) addColumnIfMissing(ctx context.Context, table, column, definit
return err
}
func (c *Catalog) clearVolatileOneDriveThumbnails(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `
UPDATE videos
SET thumbnail_url = '',
updated_at = ?
WHERE lower(COALESCE(thumbnail_url, '')) LIKE 'https://%mediap.svc.ms/transform/thumbnail%'
`, time.Now().UnixMilli())
return err
}
func (c *Catalog) hideZeroSizeVideosFromKnownDrives(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `
UPDATE videos
SET hidden = 1,
updated_at = ?
WHERE COALESCE(size_bytes, 0) <= 0
AND COALESCE(hidden, 0) = 0
AND EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
)
`, time.Now().UnixMilli())
return err
}
func (c *Catalog) seedSystemTags(ctx context.Context) error {
for _, label := range fixedtags.Labels {
if _, err := c.ensureTag(ctx, label, fixedtags.AliasesFor(label), "system"); err != nil {
+211
View File
@@ -2,6 +2,7 @@ package catalog
import (
"context"
"database/sql"
"testing"
"time"
)
@@ -69,6 +70,66 @@ func TestCreateTagAndClassifyAddsTagToMatchingExistingVideos(t *testing.T) {
}
}
func TestOpenMigratesLegacyVideosWithoutFileName(t *testing.T) {
path := t.TempDir() + "/catalog.db"
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open raw db: %v", err)
}
if _, err := db.Exec(`
CREATE TABLE videos (
id TEXT PRIMARY KEY,
drive_id TEXT NOT NULL,
file_id TEXT NOT NULL,
content_hash TEXT DEFAULT '',
parent_id TEXT,
title TEXT NOT NULL,
author TEXT,
tags TEXT,
duration_seconds INTEGER DEFAULT 0,
size_bytes INTEGER DEFAULT 0,
ext TEXT,
quality TEXT,
thumbnail_url TEXT,
preview_file_id TEXT,
preview_local TEXT,
preview_status TEXT DEFAULT 'pending',
views INTEGER DEFAULT 0,
favorites INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
dislikes INTEGER DEFAULT 0,
category TEXT,
hidden INTEGER DEFAULT 0,
tags_manual INTEGER DEFAULT 0,
badges TEXT,
description TEXT,
published_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`); err != nil {
t.Fatalf("create legacy videos table: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close raw db: %v", err)
}
cat, err := Open(path)
if err != nil {
t.Fatalf("open migrated catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
var fileNameDefault string
if err := cat.db.QueryRow(`SELECT COALESCE(file_name, '') FROM videos LIMIT 1`).Scan(&fileNameDefault); err != nil && err != sql.ErrNoRows {
t.Fatalf("query migrated file_name column: %v", err)
}
}
func TestSetManualVideoTagsRejectsUnknownLabels(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
@@ -268,6 +329,156 @@ func TestMigrateCollapsesAVCodeTagsIntoAV(t *testing.T) {
}
}
func TestMigrateClearsVolatileOneDriveThumbnailURLs(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() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
if err := cat.UpsertDrive(ctx, &Drive{
ID: "onedrive-main",
Kind: "onedrive",
Name: "OneDrive",
RootID: "root",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed onedrive: %v", err)
}
videos := []*Video{
{
ID: "onedrive-video",
DriveID: "onedrive-main",
FileID: "file-1",
Title: "OneDrive",
ThumbnailURL: "https://westus21-mediap.svc.ms/transform/thumbnail?provider=spo&tempauth=expired",
},
{
ID: "local-thumb-video",
DriveID: "onedrive-main",
FileID: "file-2",
Title: "Local thumb",
ThumbnailURL: "/p/thumb/local-thumb-video",
},
{
ID: "pikpak-video",
DriveID: "pikpak-main",
FileID: "file-3",
Title: "PikPak",
ThumbnailURL: "https://sg-thumbnail-drive.mypikpak.net/v0/screenshot-thumbnails/demo",
},
}
for _, v := range videos {
v.PublishedAt = now
v.CreatedAt = now
v.UpdatedAt = now
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed video %s: %v", v.ID, err)
}
}
if err := cat.migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
got, err := cat.GetVideo(ctx, "onedrive-video")
if err != nil {
t.Fatalf("get onedrive video: %v", err)
}
if got.ThumbnailURL != "" {
t.Fatalf("onedrive thumbnail = %q, want cleared", got.ThumbnailURL)
}
local, err := cat.GetVideo(ctx, "local-thumb-video")
if err != nil {
t.Fatalf("get local thumb video: %v", err)
}
if local.ThumbnailURL != "/p/thumb/local-thumb-video" {
t.Fatalf("local thumbnail = %q, want preserved", local.ThumbnailURL)
}
pikpak, err := cat.GetVideo(ctx, "pikpak-video")
if err != nil {
t.Fatalf("get pikpak video: %v", err)
}
if pikpak.ThumbnailURL == "" {
t.Fatal("pikpak thumbnail was cleared")
}
}
func TestMigrateHidesZeroSizeVideosForKnownDrives(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() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
if err := cat.UpsertDrive(ctx, &Drive{
ID: "drive-main",
Kind: "onedrive",
Name: "OneDrive",
RootID: "root",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
for _, v := range []*Video{
{ID: "empty-video", DriveID: "drive-main", FileID: "file-1", Title: "Empty", Size: 0},
{ID: "normal-video", DriveID: "drive-main", FileID: "file-2", Title: "Normal", Size: 123},
{ID: "orphan-empty-video", DriveID: "unknown-drive", FileID: "file-3", Title: "Orphan", Size: 0},
} {
v.PublishedAt = now
v.CreatedAt = now
v.UpdatedAt = now
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed video %s: %v", v.ID, err)
}
}
if err := cat.migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
empty, err := cat.GetVideo(ctx, "empty-video")
if err != nil {
t.Fatalf("get empty video: %v", err)
}
if !empty.Hidden {
t.Fatal("empty video was not hidden")
}
normal, err := cat.GetVideo(ctx, "normal-video")
if err != nil {
t.Fatalf("get normal video: %v", err)
}
if normal.Hidden {
t.Fatal("normal video was hidden")
}
orphan, err := cat.GetVideo(ctx, "orphan-empty-video")
if err != nil {
t.Fatalf("get orphan empty video: %v", err)
}
if orphan.Hidden {
t.Fatal("orphan empty video without a known drive was hidden")
}
}
func TestListVideosHidesDuplicateContentHashes(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
@@ -0,0 +1,103 @@
package localupload
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/video-site/backend/internal/drives"
)
const DriveID = "local-upload"
type Driver struct {
uploadDirPath string
}
func New(uploadDir string) *Driver {
return &Driver{uploadDirPath: uploadDir}
}
func (d *Driver) Kind() string { return "local-upload" }
func (d *Driver) ID() string { return DriveID }
func (d *Driver) Init(context.Context) error {
return os.MkdirAll(d.uploadDir(), 0o755)
}
func (d *Driver) List(context.Context, string) ([]drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
path, err := d.uploadPath(fileID)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
return &drives.Entry{
ID: fileID,
Name: fileID,
Size: info.Size(),
IsDir: info.IsDir(),
ModTime: info.ModTime(),
}, nil
}
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
path, err := d.uploadPath(fileID)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.IsDir() || info.Size() == 0 {
return nil, os.ErrNotExist
}
return &drives.StreamLink{
URL: path,
Expires: time.Now().Add(24 * time.Hour),
}, nil
}
func (d *Driver) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) RootID() string { return d.uploadDir() }
func (d *Driver) uploadDir() string {
return d.uploadDirPath
}
func (d *Driver) uploadPath(fileID string) (string, error) {
if strings.TrimSpace(fileID) == "" || filepath.Base(fileID) != fileID {
return "", errors.New("invalid upload file id")
}
root, err := filepath.Abs(d.uploadDir())
if err != nil {
return "", err
}
path, err := filepath.Abs(filepath.Join(root, fileID))
if err != nil {
return "", err
}
if path != root && !strings.HasPrefix(path, root+string(os.PathSeparator)) {
return "", errors.New("invalid upload file id")
}
return path, nil
}
@@ -0,0 +1,40 @@
package localupload
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStreamURLReturnsStoredUploadPath(t *testing.T) {
uploadDir := filepath.Join(t.TempDir(), "uploads")
drv := New(uploadDir)
if err := drv.Init(context.Background()); err != nil {
t.Fatalf("init: %v", err)
}
path := filepath.Join(uploadDir, "upload-1.mp4")
if err := os.WriteFile(path, []byte("video"), 0o644); err != nil {
t.Fatalf("write upload: %v", err)
}
link, err := drv.StreamURL(context.Background(), "upload-1.mp4")
if err != nil {
t.Fatalf("stream url: %v", err)
}
if link.URL != path {
t.Fatalf("url = %q, want %q", link.URL, path)
}
}
func TestStreamURLRejectsPathTraversal(t *testing.T) {
drv := New(t.TempDir())
_, err := drv.StreamURL(context.Background(), "../secret.mp4")
if err == nil || !strings.Contains(err.Error(), "invalid upload file id") {
t.Fatalf("error = %v, want invalid upload file id", err)
}
}
+7 -13
View File
@@ -115,7 +115,6 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error)
if first {
req.SetQueryParams(map[string]string{
"$top": "1000",
"$expand": "thumbnails($select=medium)",
"$select": "id,name,size,fileSystemInfo,content.downloadUrl,file,parentReference,folder",
})
}
@@ -404,19 +403,14 @@ func itemToEntry(item graphItem, fallbackParentID string) drives.Entry {
if mimeType == "" && !isDir {
mimeType = guessMime(item.Name)
}
thumb := ""
if len(item.Thumbnails) > 0 {
thumb = item.Thumbnails[0].Medium.URL
}
return drives.Entry{
ID: item.ID,
Name: item.Name,
Size: item.Size,
IsDir: isDir,
ParentID: parentID,
MimeType: mimeType,
ModTime: mod,
ThumbnailURL: thumb,
ID: item.ID,
Name: item.Name,
Size: item.Size,
IsDir: isDir,
ParentID: parentID,
MimeType: mimeType,
ModTime: mod,
}
}
@@ -154,7 +154,7 @@ func TestListFollowsPaginationAndMapsEntries(t *testing.T) {
if !got[0].IsDir || got[0].ID != "folder-id" || got[0].ParentID != "root" {
t.Fatalf("folder entry = %#v", got[0])
}
if got[1].IsDir || got[1].MimeType != "video/mp4" || got[1].ThumbnailURL != "https://thumb.example/demo.jpg" {
if got[1].IsDir || got[1].MimeType != "video/mp4" || got[1].ThumbnailURL != "" {
t.Fatalf("file entry = %#v", got[1])
}
if got[1].ModTime.IsZero() {
+51 -16
View File
@@ -661,36 +661,71 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) {
}
link, err := w.Drive.StreamURL(ctx, v.FileID)
if err != nil {
if w.pauseForRateLimit(err, "streamURL", v.Title) {
return
}
log.Printf("[thumb] streamURL %s: %v", v.Title, err)
return
}
duration := float64(v.DurationSeconds)
if duration <= 0 {
if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 {
duration = dur
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
DurationSeconds: int(dur),
})
} else if err != nil && w.pauseForRateLimit(err, "probe", v.Title) {
if localLink, ok := localPreviewLink(v); ok {
link = localLink
} else {
if w.pauseForRateLimit(err, "streamURL", v.Title) {
return
}
log.Printf("[thumb] streamURL %s: %v", v.Title, err)
return
}
}
if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, duration); err != nil {
if err := w.generateThumbnailFromLink(ctx, v, link); err != nil {
if localLink, ok := localPreviewLink(v); ok && link.URL != localLink.URL {
if localErr := w.generateThumbnailFromLink(ctx, v, localLink); localErr == nil {
return
}
}
if w.pauseForRateLimit(err, "generate", v.Title) {
return
}
log.Printf("[thumb] generate %s: %v", v.Title, err)
return
}
}
func (w *ThumbWorker) generateThumbnailFromLink(ctx context.Context, v *catalog.Video, link *drives.StreamLink) error {
duration := thumbnailDurationHint(v, link)
if duration <= 0 {
if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 {
duration = dur
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
DurationSeconds: int(dur),
})
} else if err != nil {
return err
}
}
if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, duration); err != nil {
return err
}
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
ThumbnailURL: "/p/thumb/" + v.ID,
})
log.Printf("[thumb] ready %s", v.Title)
return nil
}
func thumbnailDurationHint(v *catalog.Video, link *drives.StreamLink) float64 {
if link != nil && v.PreviewLocal != "" && filepath.Clean(link.URL) == filepath.Clean(v.PreviewLocal) {
return 0
}
return float64(v.DurationSeconds)
}
func localPreviewLink(v *catalog.Video) (*drives.StreamLink, bool) {
if v.PreviewLocal == "" {
return nil, false
}
clean := filepath.Clean(v.PreviewLocal)
info, err := os.Stat(clean)
if err != nil || info.IsDir() || info.Size() == 0 {
return nil, false
}
return &drives.StreamLink{URL: clean}, true
}
func (w *Worker) process(ctx context.Context, v *catalog.Video) {
+39 -1
View File
@@ -47,6 +47,36 @@ func TestThumbWorkerUpdatesThumbnailWithoutChangingPreviewStatus(t *testing.T) {
}
}
func TestThumbWorkerFallsBackToLocalPreviewWhenDriveStreamFails(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-worker-local-preview")
localPreview := filepath.Join(t.TempDir(), "preview.mp4")
if err := os.WriteFile(localPreview, []byte("preview"), 0o644); err != nil {
t.Fatalf("write local preview: %v", err)
}
video.PreviewLocal = localPreview
if err := cat.UpsertVideo(ctx, video); err != nil {
t.Fatalf("update video: %v", err)
}
gen := &fakeThumbGenerator{}
drv := &previewFakeDrive{streamErr: errors.New("remote unavailable")}
worker := NewThumbWorker(gen, cat, drv)
worker.process(ctx, video)
got, err := cat.GetVideo(ctx, video.ID)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.ThumbnailURL != "/p/thumb/"+video.ID {
t.Fatalf("thumbnail = %q, want generated thumb URL", got.ThumbnailURL)
}
if gen.thumbnailURL != localPreview {
t.Fatalf("thumbnail source = %q, want local preview %q", gen.thumbnailURL, localPreview)
}
}
func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "preview-worker-video")
@@ -187,15 +217,19 @@ func seedPreviewTestVideo(t *testing.T, id string) (*catalog.Catalog, *catalog.V
type fakeThumbGenerator struct {
thumbnailVideoID string
thumbnailDuration float64
thumbnailURL string
}
func (g *fakeThumbGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) {
return 42, nil
}
func (g *fakeThumbGenerator) GenerateThumbnail(_ context.Context, _ *drives.StreamLink, videoID string, duration float64) (string, error) {
func (g *fakeThumbGenerator) GenerateThumbnail(_ context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) {
g.thumbnailVideoID = videoID
g.thumbnailDuration = duration
if link != nil {
g.thumbnailURL = link.URL
}
return "/tmp/" + videoID + ".jpg", nil
}
@@ -226,6 +260,7 @@ func (g *fakeTeaserGenerator) MoveToLocal(_ string, videoID string) (string, err
type previewFakeDrive struct {
streamFileID string
streamErr error
}
func (d *previewFakeDrive) Kind() string { return "fake" }
@@ -241,6 +276,9 @@ func (d *previewFakeDrive) Stat(context.Context, string) (*drives.Entry, error)
}
func (d *previewFakeDrive) StreamURL(_ context.Context, fileID string) (*drives.StreamLink, error) {
d.streamFileID = fileID
if d.streamErr != nil {
return nil, d.streamErr
}
return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil
}
func (d *previewFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
+33 -7
View File
@@ -85,6 +85,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
if !s.Exts[ext] {
continue
}
if e.Size <= 0 {
continue
}
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
parsed := Parse(e.Name)
@@ -101,32 +104,36 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
existing, _ := s.Catalog.GetVideo(ctx, id)
if existing != nil {
patch := catalog.VideoMetaPatch{}
if e.Hash != "" && existing.ContentHash == "" {
_ = s.Catalog.UpdateVideoMeta(ctx, id, catalog.VideoMetaPatch{ContentHash: e.Hash})
patch.ContentHash = e.Hash
existing.ContentHash = e.Hash
}
if dup := s.findDuplicateByHash(ctx, e.Hash, id); dup != nil {
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
continue
if e.Name != "" && existing.FileName == "" {
patch.FileName = e.Name
existing.FileName = e.Name
}
// 已存在但轻量元数据空缺时,顺便补齐。
patch := catalog.VideoMetaPatch{}
if existing.Category == "" && dirName != "" {
patch.Category = dirName
}
if existing.ThumbnailURL == "" && e.ThumbnailURL != "" {
patch.ThumbnailURL = e.ThumbnailURL
}
if patch.Category != "" || patch.ThumbnailURL != "" {
if patch.Category != "" || patch.ThumbnailURL != "" || patch.ContentHash != "" || patch.FileName != "" {
_ = s.Catalog.UpdateVideoMeta(ctx, id, patch)
}
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
continue
}
if !sameTags(existing.Tags, tags) {
_ = s.Catalog.SetAutoVideoTags(ctx, id, tags)
}
continue
}
if dup := s.findDuplicateByHash(ctx, e.Hash, id); dup != nil {
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
continue
}
@@ -136,6 +143,7 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
ID: id,
DriveID: s.Drive.ID(),
FileID: e.ID,
FileName: e.Name,
ContentHash: e.Hash,
ParentID: e.ParentID,
Title: parsed.Title,
@@ -163,6 +171,13 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
return nil
}
func (s *Scanner) findDuplicate(ctx context.Context, hash, fileName string, size int64, currentID string) *catalog.Video {
if dup := s.findDuplicateByHash(ctx, hash, currentID); dup != nil {
return dup
}
return s.findDuplicateByFileSignature(ctx, fileName, size, currentID)
}
func (s *Scanner) findDuplicateByHash(ctx context.Context, hash, currentID string) *catalog.Video {
if hash == "" {
return nil
@@ -174,6 +189,17 @@ func (s *Scanner) findDuplicateByHash(ctx context.Context, hash, currentID strin
return dup
}
func (s *Scanner) findDuplicateByFileSignature(ctx context.Context, fileName string, size int64, currentID string) *catalog.Video {
if fileName == "" || size <= 0 {
return nil
}
dup, err := s.Catalog.FindVideoByFileSignature(ctx, fileName, size)
if err != nil || dup == nil || dup.ID == currentID {
return nil
}
return dup
}
func (s *Scanner) backfillDuplicateThumbnail(ctx context.Context, canonical *catalog.Video, thumbnailURL string) {
if canonical.ThumbnailURL != "" || thumbnailURL == "" {
return
+92
View File
@@ -2,6 +2,7 @@ package scanner
import (
"context"
"database/sql"
"io"
"testing"
"time"
@@ -51,6 +52,41 @@ func TestRunPersistsRemoteThumbnailFromDriveEntry(t *testing.T) {
}
}
func TestRunIgnoresZeroSizeVideoFiles(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)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{{
ID: "empty-file",
Name: "empty.mp4",
Size: 0,
MimeType: "video/mp4",
ModTime: time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC),
}},
}
sc := New(cat, drv, []string{".mp4"}, 5, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 0 {
t.Fatalf("added = %d, want 0", stats.Added)
}
if _, err := cat.GetVideo(ctx, "fake-drive-empty-file"); err != sql.ErrNoRows {
t.Fatalf("get zero-size video error = %v, want sql.ErrNoRows", err)
}
}
func TestRunBackfillsRemoteThumbnailForExistingVideo(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -332,6 +368,62 @@ func TestRunSkipsDuplicateFileHashes(t *testing.T) {
}
}
func TestRunSkipsDuplicateFileNamesWithSameSizeWhenHashesMissing(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()
drv := &scannerFakeDrive{
entries: []drives.Entry{
{
ID: "file-1",
Name: "same-name.mp4",
Size: 123,
ModTime: now,
},
{
ID: "file-2",
Name: "same-name.mp4",
Size: 123,
ModTime: now,
},
{
ID: "file-3",
Name: "same-name.mp4",
Size: 456,
ModTime: now,
},
},
}
addedIDs := []string{}
sc := New(cat, drv, []string{".mp4"}, 5, func(v *catalog.Video) {
addedIDs = append(addedIDs, v.ID)
})
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 2 {
t.Fatalf("added = %d, want 2", stats.Added)
}
wantAdded := []string{"fake-drive-file-1", "fake-drive-file-3"}
if !sameStrings(addedIDs, wantAdded) {
t.Fatalf("on new ids = %#v, want %#v", addedIDs, wantAdded)
}
if _, err := cat.GetVideo(ctx, "fake-drive-file-2"); err != sql.ErrNoRows {
t.Fatalf("duplicate video lookup error = %v, want sql.ErrNoRows", err)
}
}
type scannerFakeDrive struct {
entries []drives.Entry
}
+9
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom";
import HomePage from "@/pages/HomePage";
import ListingPage from "@/pages/ListingPage";
import UploadPage from "@/pages/UploadPage";
import VideoDetailPage from "@/pages/VideoDetailPage";
import { AdminLayout } from "@/admin/AdminLayout";
import { LoginPage } from "@/admin/LoginPage";
@@ -31,6 +32,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/upload"
element={
<RequireAuth>
<UploadPage />
</RequireAuth>
}
/>
<Route
path="/video/:id"
element={
+43 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Plus, RefreshCw, Trash2 } from "lucide-react";
import { Plus, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
@@ -38,6 +38,7 @@ export function DrivesPage() {
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [regenFailedId, setRegenFailedId] = useState("");
const { show } = useToast();
async function refresh() {
@@ -123,6 +124,19 @@ export function DrivesPage() {
}
}
async function handleRegenFailed(d: api.AdminDrive) {
setRegenFailedId(d.id);
try {
await api.regenFailedPreviews(d.id);
show("已触发失败 teaser 重新生成", "success");
refresh();
} catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error");
} finally {
setRegenFailedId("");
}
}
return (
<section>
<header className="admin-page__header">
@@ -147,6 +161,7 @@ export function DrivesPage() {
<th>ID</th>
<th></th>
<th></th>
<th>Teaser</th>
<th className="is-actions"></th>
</tr>
</thead>
@@ -162,10 +177,21 @@ export function DrivesPage() {
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
{d.scanRootId || d.rootId}
</td>
<td>
<TeaserCounts drive={d} />
</td>
<td className="is-actions">
<button className="admin-btn" onClick={() => handleRescan(d)}>
<RefreshCw size={13} />
</button>{" "}
<button
className="admin-btn"
disabled={(d.teaserFailedCount ?? 0) <= 0 || regenFailedId === d.id}
onClick={() => handleRegenFailed(d)}
>
<RotateCcw size={13} />
{regenFailedId === d.id ? "触发中..." : "重新生成失败 teaser"}
</button>{" "}
<button className="admin-btn" onClick={() => openEdit(d)}>
</button>{" "}
@@ -204,6 +230,22 @@ export function DrivesPage() {
);
}
function TeaserCounts({ drive }: { drive: api.AdminDrive }) {
return (
<div className="admin-teaser-counts">
<span className="admin-drive-teaser__metric is-ready">
{drive.teaserReadyCount ?? 0}
</span>
<span className="admin-drive-teaser__metric is-pending">
{drive.teaserPendingCount ?? 0}
</span>
<span className="admin-drive-teaser__metric is-failed">
{drive.teaserFailedCount ?? 0}
</span>
</div>
);
}
function StatusTag({
status,
error,
+7
View File
@@ -99,6 +99,13 @@ export function rescan(id: string) {
);
}
export function regenFailedPreviews(id: string) {
return request<{ ok: boolean }>(
`/drives/${encodeURIComponent(id)}/previews/failed/regenerate`,
{ method: "POST" }
);
}
// ---------- Videos ----------
export type AdminVideo = {
+28
View File
@@ -46,6 +46,24 @@ export function hideVideo(id: string): Promise<{ ok: boolean }> {
);
}
export type UploadVideoInput = {
file: File;
title: string;
tags: string[];
};
export function uploadVideo(input: UploadVideoInput): Promise<VideoItem> {
const body = new FormData();
body.append("file", input.file);
if (input.title.trim()) {
body.append("title", input.title.trim());
}
for (const tag of input.tags) {
body.append("tags", tag);
}
return apiForm<VideoItem>("/api/upload", body);
}
export type TagItem = { id: string; label: string; count?: number };
export function fetchTags(): Promise<TagItem[]> {
@@ -67,3 +85,13 @@ async function apiJSON<T>(path: string, init: RequestInit): Promise<T> {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function apiForm<T>(path: string, body: FormData): Promise<T> {
const res = await fetch(path, {
method: "POST",
credentials: "include",
body,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
+130
View File
@@ -0,0 +1,130 @@
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Check, Film, UploadCloud } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { SectionHeader } from "@/components/SectionHeader";
import { uploadVideo } from "@/data/videos";
import type { VideoItem } from "@/types";
const UPLOAD_TAGS = ["奶子", "臀", "口角", "女大", "人妻", "AV"];
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [uploaded, setUploaded] = useState<VideoItem | null>(null);
useEffect(() => {
document.title = "上传视频 · 视频聚合站";
}, []);
const fileMeta = useMemo(() => {
if (!file) return "";
const mb = file.size / 1024 / 1024;
return `${file.name} · ${mb >= 1 ? mb.toFixed(1) : mb.toFixed(2)} MB`;
}, [file]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
setFile(event.target.files?.[0] ?? null);
setUploaded(null);
setError("");
}
function toggleTag(tag: string) {
setTags((current) =>
current.includes(tag)
? current.filter((item) => item !== tag)
: [...current, tag]
);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file || saving) return;
setSaving(true);
setError("");
setUploaded(null);
try {
const video = await uploadVideo({ file, title, tags });
setUploaded(video);
setFile(null);
setTitle("");
setTags([]);
} catch {
setError("上传失败,请检查文件格式后重试");
} finally {
setSaving(false);
}
}
return (
<AppShell>
<div className="container page-section">
<SectionHeader title="上传视频" extra="本地视频会加入站内列表" />
<form className="upload-panel" onSubmit={handleSubmit}>
<label className="upload-drop">
<input
type="file"
accept="video/*,.avi,.mkv,.mov,.mp4,.webm"
onChange={handleFileChange}
/>
<span className="upload-drop__icon">
<UploadCloud size={28} />
</span>
<span className="upload-drop__title">
{file ? fileMeta : "选择视频文件"}
</span>
</label>
<label className="upload-field">
<span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="不填则使用当前时间戳"
maxLength={120}
/>
</label>
<div className="upload-field">
<span></span>
<div className="upload-tags">
{UPLOAD_TAGS.map((tag) => {
const active = tags.includes(tag);
return (
<button
key={tag}
type="button"
className={`upload-tag ${active ? "is-active" : ""}`}
onClick={() => toggleTag(tag)}
aria-pressed={active}
>
{active ? <Check size={14} /> : null}
{tag}
</button>
);
})}
</div>
</div>
{error ? <div className="upload-message is-error">{error}</div> : null}
{uploaded ? (
<div className="upload-message is-success">
<Check size={16} />
<Link to={uploaded.href}> {uploaded.title}</Link>
</div>
) : null}
<div className="upload-actions">
<button className="upload-submit" type="submit" disabled={!file || saving}>
<Film size={16} />
{saving ? "上传中" : "上传"}
</button>
</div>
</form>
</div>
</AppShell>
);
}
+7
View File
@@ -266,6 +266,13 @@
color: var(--color-danger);
}
.admin-teaser-counts {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 150px;
}
.admin-table {
width: 100%;
border-collapse: collapse;
+162
View File
@@ -139,6 +139,168 @@
color: var(--color-muted);
}
.upload-panel {
display: grid;
gap: var(--space-4);
padding: 16px;
background: #111111;
border: 1px solid var(--color-card-border);
border-top: 0;
color: var(--color-muted-light);
}
.upload-drop {
position: relative;
display: grid;
place-items: center;
gap: 8px;
min-height: 164px;
padding: 18px;
border: 1px dashed #4a4a4a;
border-radius: 3px;
background: #171717;
color: var(--color-muted-light);
text-align: center;
cursor: pointer;
}
.upload-drop:hover {
border-color: var(--color-accent);
}
.upload-drop input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.upload-drop__icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #232323;
color: var(--color-accent);
}
.upload-drop__title {
max-width: 100%;
color: var(--color-text-invert);
font-size: 14px;
overflow-wrap: anywhere;
}
.upload-field {
display: grid;
gap: 8px;
}
.upload-field > span {
color: var(--color-muted);
font-size: 12px;
}
.upload-field input {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid #363636;
border-radius: 2px;
background: #181818;
color: var(--color-text-invert);
}
.upload-field input::placeholder {
color: #777777;
}
.upload-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.upload-tag {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 58px;
min-height: 32px;
padding: 6px 10px;
border: 1px solid #383838;
border-radius: 2px;
background: #1b1b1b;
color: var(--color-muted-light);
font-size: 12px;
}
.upload-tag:hover {
border-color: var(--color-accent);
color: var(--color-text-invert);
}
.upload-tag.is-active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-text-invert);
}
.upload-message {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 7px 10px;
border-radius: 2px;
font-size: 12px;
}
.upload-message.is-error {
background: #2b1515;
color: #ffb3b3;
border: 1px solid #5a2424;
}
.upload-message.is-success {
background: #132414;
color: #b9f0bd;
border: 1px solid #2f6733;
}
.upload-message a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.upload-actions {
display: flex;
justify-content: flex-end;
}
.upload-submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 108px;
min-height: 38px;
padding: 8px 14px;
border-radius: 2px;
background: var(--color-accent);
color: var(--color-text-invert);
font-weight: 600;
}
.upload-submit:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 1024px) {
.promo-strip {
grid-template-columns: repeat(2, 1fr);
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/admin/AdminLayout.tsx","./src/admin/AuthContext.tsx","./src/admin/DrivesPage.tsx","./src/admin/LoginPage.tsx","./src/admin/Modal.tsx","./src/admin/PreviewToggle.tsx","./src/admin/RequireAuth.tsx","./src/admin/TagsPage.tsx","./src/admin/ToastContext.tsx","./src/admin/VideosPage.tsx","./src/admin/api.ts","./src/components/AppShell.tsx","./src/components/BackToTop.tsx","./src/components/CommentPanel.tsx","./src/components/Footer.tsx","./src/components/MainNav.tsx","./src/components/Pagination.tsx","./src/components/PreviewVideo.tsx","./src/components/PromoStrip.tsx","./src/components/RecommendedRail.tsx","./src/components/SearchPanel.tsx","./src/components/SectionHeader.tsx","./src/components/SortToolbar.tsx","./src/components/SubNav.tsx","./src/components/TagCloud.tsx","./src/components/TopBar.tsx","./src/components/VideoActions.tsx","./src/components/VideoCard.tsx","./src/components/VideoGrid.tsx","./src/components/VideoInfoPanel.tsx","./src/components/VideoPlayer.tsx","./src/data/categories.ts","./src/data/tags.ts","./src/data/videos.ts","./src/lib/format.ts","./src/lib/previewController.ts","./src/lib/previewIntent.ts","./src/lib/useInViewport.ts","./src/pages/HomePage.tsx","./src/pages/ListingPage.tsx","./src/pages/VideoDetailPage.tsx"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/admin/AdminLayout.tsx","./src/admin/AuthContext.tsx","./src/admin/DrivesPage.tsx","./src/admin/LoginPage.tsx","./src/admin/Modal.tsx","./src/admin/PreviewToggle.tsx","./src/admin/RequireAuth.tsx","./src/admin/TagsPage.tsx","./src/admin/ToastContext.tsx","./src/admin/VideosPage.tsx","./src/admin/api.ts","./src/components/AppShell.tsx","./src/components/BackToTop.tsx","./src/components/CommentPanel.tsx","./src/components/Footer.tsx","./src/components/MainNav.tsx","./src/components/Pagination.tsx","./src/components/PreviewVideo.tsx","./src/components/PromoStrip.tsx","./src/components/RecommendedRail.tsx","./src/components/SearchPanel.tsx","./src/components/SectionHeader.tsx","./src/components/SortToolbar.tsx","./src/components/SubNav.tsx","./src/components/TagCloud.tsx","./src/components/TopBar.tsx","./src/components/VideoActions.tsx","./src/components/VideoCard.tsx","./src/components/VideoGrid.tsx","./src/components/VideoInfoPanel.tsx","./src/components/VideoPlayer.tsx","./src/data/categories.ts","./src/data/tags.ts","./src/data/videos.ts","./src/lib/format.ts","./src/lib/previewController.ts","./src/lib/previewIntent.ts","./src/lib/useInViewport.ts","./src/pages/HomePage.tsx","./src/pages/ListingPage.tsx","./src/pages/UploadPage.tsx","./src/pages/VideoDetailPage.tsx"],"version":"5.6.3"}