mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
feat: add upload flow and drive maintenance tools
This commit is contained in:
+138
-6
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -16,7 +16,7 @@ storage:
|
||||
local_preview_dir: "./data/previews"
|
||||
|
||||
scanner:
|
||||
# 扫描间隔(秒),0 表示只启动时扫一次
|
||||
# 自动扫盘最小间隔(秒);只在每天 02:00-07:00 触发,0 表示仅允许管理员手动重扫
|
||||
interval_seconds: 21600
|
||||
# 单次扫描每家网盘目录递归层数上限
|
||||
max_depth: 5
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user