Merge pull request #10 from yiyiis/feature/chunk-upload
feat: 实现视频分片上传与断点续传
This commit is contained in:
@@ -62,6 +62,7 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
|
|||||||
}
|
}
|
||||||
videoService := video.NewVideoService(videoRepository, cache, popularityMQ)
|
videoService := video.NewVideoService(videoRepository, cache, popularityMQ)
|
||||||
videoHandler := video.NewVideoHandler(videoService, accountService)
|
videoHandler := video.NewVideoHandler(videoService, accountService)
|
||||||
|
chunkHandler := video.NewChunkUploadHandler(cache)
|
||||||
videoGroup := r.Group("/video")
|
videoGroup := r.Group("/video")
|
||||||
{
|
{
|
||||||
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
|
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
|
||||||
@@ -73,6 +74,10 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
|
|||||||
protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo)
|
protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo)
|
||||||
protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover)
|
protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover)
|
||||||
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
|
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
|
||||||
|
protectedVideoGroup.POST("/chunk/init", chunkHandler.InitChunkUpload)
|
||||||
|
protectedVideoGroup.POST("/chunk/upload", chunkHandler.UploadChunk)
|
||||||
|
protectedVideoGroup.POST("/chunk/status", chunkHandler.ChunkStatus)
|
||||||
|
protectedVideoGroup.POST("/chunk/complete", chunkHandler.CompleteChunkUpload)
|
||||||
}
|
}
|
||||||
// like
|
// like
|
||||||
likeMQ, err := rabbitmq.NewLikeMQ(rmq)
|
likeMQ, err := rabbitmq.NewLikeMQ(rmq)
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type Client struct {
|
|||||||
|
|
||||||
const defaultKeyPrefix = "v1:"
|
const defaultKeyPrefix = "v1:"
|
||||||
|
|
||||||
|
func NewClient(rdb *redis.Client, keyPrefix string) *Client {
|
||||||
|
return &Client{rdb: rdb, keyPrefix: keyPrefix}
|
||||||
|
}
|
||||||
|
|
||||||
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
|
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
||||||
|
|||||||
55
backend/internal/video/chunk_entity.go
Normal file
55
backend/internal/video/chunk_entity.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
const ChunkSize = 5 << 20 // 5 MB
|
||||||
|
|
||||||
|
type ChunkUploadSession struct {
|
||||||
|
UploadID string `json:"upload_id"`
|
||||||
|
AccountID uint `json:"account_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
ChunkSize int64 `json:"chunk_size"`
|
||||||
|
TotalChunks int `json:"total_chunks"`
|
||||||
|
FileHash string `json:"file_hash"`
|
||||||
|
UploadedBits []bool `json:"uploaded_bits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChunkUploadSession) UploadedChunks() []int {
|
||||||
|
var indices []int
|
||||||
|
for i, uploaded := range s.UploadedBits {
|
||||||
|
if uploaded {
|
||||||
|
indices = append(indices, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indices
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChunkUploadSession) IsComplete() bool {
|
||||||
|
for _, b := range s.UploadedBits {
|
||||||
|
if !b {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitChunkUploadRequest struct {
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
FileSize int64 `json:"file_size" binding:"required,min=1"`
|
||||||
|
ChunkSize int64 `json:"chunk_size" binding:"required,min=1"`
|
||||||
|
TotalChunks int `json:"total_chunks" binding:"required,min=1"`
|
||||||
|
FileHash string `json:"file_hash" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadChunkRequest struct {
|
||||||
|
UploadID string `form:"upload_id" binding:"required"`
|
||||||
|
ChunkIndex int `form:"chunk_index" binding:"min=0"`
|
||||||
|
ChunkHash string `form:"chunk_hash" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkStatusRequest struct {
|
||||||
|
UploadID string `json:"upload_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompleteChunkUploadRequest struct {
|
||||||
|
UploadID string `json:"upload_id" binding:"required"`
|
||||||
|
}
|
||||||
344
backend/internal/video/chunk_handler.go
Normal file
344
backend/internal/video/chunk_handler.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
type ChunkUploadHandler struct {
|
||||||
|
cache *rediscache.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChunkUploadHandler(cache *rediscache.Client) *ChunkUploadHandler {
|
||||||
|
return &ChunkUploadHandler{cache: cache}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) sessionKey(uploadID string) string {
|
||||||
|
return h.cache.Key("chunk_upload:%s", uploadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) hashKey(accountID uint, fileHash string) string {
|
||||||
|
return h.cache.Key("chunk_upload_hash:%d:%s", accountID, fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) getSession(ctx *gin.Context, uploadID string) (*ChunkUploadSession, error) {
|
||||||
|
b, err := h.cache.GetBytes(ctx.Request.Context(), h.sessionKey(uploadID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("upload session not found")
|
||||||
|
}
|
||||||
|
var s ChunkUploadSession
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid session data")
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) saveSession(ctx *gin.Context, s *ChunkUploadSession) error {
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.cache.SetBytes(ctx.Request.Context(), h.sessionKey(s.UploadID), b, sessionTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) InitChunkUpload(c *gin.Context) {
|
||||||
|
var req InitChunkUploadRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := jwt.GetAccountID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 200 << 20
|
||||||
|
if req.FileSize > maxSize {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file size exceeds 200MB limit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing session (resume)
|
||||||
|
hashKey := h.hashKey(accountID, req.FileHash)
|
||||||
|
existingID, err := h.cache.GetBytes(c.Request.Context(), hashKey)
|
||||||
|
if err == nil && len(existingID) > 0 {
|
||||||
|
session, sessErr := h.getSession(c, string(existingID))
|
||||||
|
if sessErr == nil {
|
||||||
|
// Refresh TTL on resume
|
||||||
|
_ = h.cache.SetBytes(c.Request.Context(), hashKey, existingID, sessionTTL)
|
||||||
|
_ = h.saveSession(c, session)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"upload_id": session.UploadID,
|
||||||
|
"uploaded_chunks": session.UploadedChunks(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := randHex(16)
|
||||||
|
uploadID := id + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
session := &ChunkUploadSession{
|
||||||
|
UploadID: uploadID,
|
||||||
|
AccountID: accountID,
|
||||||
|
Filename: req.Filename,
|
||||||
|
FileSize: req.FileSize,
|
||||||
|
ChunkSize: req.ChunkSize,
|
||||||
|
TotalChunks: req.TotalChunks,
|
||||||
|
FileHash: req.FileHash,
|
||||||
|
UploadedBits: make([]bool, req.TotalChunks),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.saveSession(c, session); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.cache.SetBytes(c.Request.Context(), hashKey, []byte(uploadID), sessionTTL); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"upload_id": uploadID,
|
||||||
|
"uploaded_chunks": []int{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) UploadChunk(c *gin.Context) {
|
||||||
|
var req UploadChunkRequest
|
||||||
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := h.getSession(c, req.UploadID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := jwt.GetAccountID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.AccountID != accountID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ChunkIndex < 0 || req.ChunkIndex >= session.TotalChunks {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid chunk_index"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UploadedBits[req.ChunkIndex] {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"chunk_index": req.ChunkIndex})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkFile, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read chunk"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer chunkFile.Close()
|
||||||
|
|
||||||
|
hash := md5.New()
|
||||||
|
if _, err := io.Copy(hash, chunkFile); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash chunk"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actualHash := fmt.Sprintf("%x", hash.Sum(nil))
|
||||||
|
|
||||||
|
if actualHash != req.ChunkHash {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "chunk hash mismatch", "expected": req.ChunkHash, "actual": actualHash})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := filepath.Join(".run", "uploads", "tmp", req.UploadID)
|
||||||
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkPath := filepath.Join(tmpDir, fmt.Sprintf("%d", req.ChunkIndex))
|
||||||
|
if _, seekErr := chunkFile.Seek(0, io.SeekStart); seekErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read chunk"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := os.Create(chunkPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save chunk"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, chunkFile); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save chunk"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.UploadedBits[req.ChunkIndex] = true
|
||||||
|
if err := h.saveSession(c, session); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"chunk_index": req.ChunkIndex})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) ChunkStatus(c *gin.Context) {
|
||||||
|
var req ChunkStatusRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := h.getSession(c, req.UploadID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := jwt.GetAccountID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.AccountID != accountID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"upload_id": session.UploadID,
|
||||||
|
"uploaded_chunks": session.UploadedChunks(),
|
||||||
|
"total_chunks": session.TotalChunks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChunkUploadHandler) CompleteChunkUpload(c *gin.Context) {
|
||||||
|
var req CompleteChunkUploadRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := h.getSession(c, req.UploadID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := jwt.GetAccountID(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.AccountID != accountID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.IsComplete() {
|
||||||
|
missing := 0
|
||||||
|
for _, uploaded := range session.UploadedBits {
|
||||||
|
if !uploaded {
|
||||||
|
missing++
|
||||||
|
if missing > 5 {
|
||||||
|
missing = 5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "not all chunks uploaded",
|
||||||
|
"missing": missing,
|
||||||
|
"completed": len(session.UploadedChunks()),
|
||||||
|
"total": session.TotalChunks,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
date := time.Now().Format("20060102")
|
||||||
|
relDir := filepath.Join("videos", fmt.Sprintf("%d", accountID), date)
|
||||||
|
root := filepath.Join(".run", "uploads")
|
||||||
|
absDir := filepath.Join(root, relDir)
|
||||||
|
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create output dir"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := randHex(16)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalPath := filepath.Join(absDir, filename+".mp4")
|
||||||
|
|
||||||
|
finalFile, err := os.Create(finalPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create final file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer finalFile.Close()
|
||||||
|
|
||||||
|
tmpDir := filepath.Join(".run", "uploads", "tmp", req.UploadID)
|
||||||
|
for i := 0; i < session.TotalChunks; i++ {
|
||||||
|
chunkPath := filepath.Join(tmpDir, fmt.Sprintf("%d", i))
|
||||||
|
cf, err := os.Open(chunkPath)
|
||||||
|
if err != nil {
|
||||||
|
finalFile.Close()
|
||||||
|
os.Remove(finalPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("chunk %d missing", i)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.Copy(finalFile, cf)
|
||||||
|
cf.Close()
|
||||||
|
if err != nil {
|
||||||
|
finalFile.Close()
|
||||||
|
os.Remove(finalPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to merge chunks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalFile.Close()
|
||||||
|
|
||||||
|
// Clean up temp chunks
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Clean up Redis session
|
||||||
|
h.cache.Del(c.Request.Context(), h.sessionKey(req.UploadID))
|
||||||
|
h.cache.Del(c.Request.Context(), h.hashKey(accountID, session.FileHash))
|
||||||
|
|
||||||
|
urlPath := fmt.Sprintf("/static/videos/%d/%s/%s.mp4", accountID, date, filename)
|
||||||
|
playURL := buildAbsoluteURL(c, urlPath)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"url": playURL,
|
||||||
|
"play_url": playURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
427
backend/internal/video/chunk_handler_test.go
Normal file
427
backend/internal/video/chunk_handler_test.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
|
||||||
|
const testAccountID uint = 1
|
||||||
|
|
||||||
|
func setupTestEnv(t *testing.T) (*ChunkUploadHandler, func()) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := rediscache.NewClient(
|
||||||
|
goredis.NewClient(&goredis.Options{Addr: mr.Addr()}),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
handler := NewChunkUploadHandler(client)
|
||||||
|
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "chunk-upload-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatalf("chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.Chdir(origDir)
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
client.Close()
|
||||||
|
mr.Close()
|
||||||
|
}
|
||||||
|
return handler, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJSONContext(t *testing.T, path string, body interface{}) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Host = "localhost"
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("accountID", testAccountID)
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMultipartContext(t *testing.T, path string, fields map[string]string, fileContent []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
|
for k, v := range fields {
|
||||||
|
if err := writer.WriteField(k, v); err != nil {
|
||||||
|
t.Fatalf("write field %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part, err := writer.CreateFormFile("file", "chunk.bin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(fileContent); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &buf)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Host = "localhost"
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = req
|
||||||
|
c.Set("accountID", testAccountID)
|
||||||
|
return c, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeMD5(data []byte) string {
|
||||||
|
h := md5.Sum(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||||
|
t.Fatalf("parse json: %v, body: %s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestChunks(t *testing.T, totalChunks, chunkSize int) ([][]byte, []string, string) {
|
||||||
|
t.Helper()
|
||||||
|
chunks := make([][]byte, totalChunks)
|
||||||
|
chunkHashes := make([]string, totalChunks)
|
||||||
|
var full bytes.Buffer
|
||||||
|
for i := 0; i < totalChunks; i++ {
|
||||||
|
data := make([]byte, chunkSize)
|
||||||
|
if _, err := rand.Read(data); err != nil {
|
||||||
|
t.Fatalf("rand read: %v", err)
|
||||||
|
}
|
||||||
|
chunks[i] = data
|
||||||
|
chunkHashes[i] = computeMD5(data)
|
||||||
|
full.Write(data)
|
||||||
|
}
|
||||||
|
return chunks, chunkHashes, computeMD5(full.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func initUpload(t *testing.T, h *ChunkUploadHandler, filename string, fileSize int64, chunkSize int64, totalChunks int, fileHash string) string {
|
||||||
|
t.Helper()
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/init", InitChunkUploadRequest{
|
||||||
|
Filename: filename,
|
||||||
|
FileSize: fileSize,
|
||||||
|
ChunkSize: chunkSize,
|
||||||
|
TotalChunks: totalChunks,
|
||||||
|
FileHash: fileHash,
|
||||||
|
})
|
||||||
|
h.InitChunkUpload(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("init: expected 200, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseJSON(t, rec)
|
||||||
|
return resp["upload_id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadChunk(t *testing.T, h *ChunkUploadHandler, uploadID string, chunkIndex int, chunkHash string, chunkData []byte) {
|
||||||
|
t.Helper()
|
||||||
|
c, rec := newMultipartContext(t, "/video/chunk/upload", map[string]string{
|
||||||
|
"upload_id": uploadID,
|
||||||
|
"chunk_index": fmt.Sprintf("%d", chunkIndex),
|
||||||
|
"chunk_hash": chunkHash,
|
||||||
|
}, chunkData)
|
||||||
|
h.UploadChunk(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload chunk %d: expected 200, got %d, body: %s", chunkIndex, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeUpload(t *testing.T, h *ChunkUploadHandler, uploadID string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/complete", CompleteChunkUploadRequest{UploadID: uploadID})
|
||||||
|
h.CompleteChunkUpload(c)
|
||||||
|
return parseJSON(t, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMergedFile extracts the file path from the response URL and reads it.
|
||||||
|
// URL format: http://localhost/static/videos/<accountID>/<date>/<name>.mp4
|
||||||
|
func readMergedFile(t *testing.T, resp map[string]interface{}) []byte {
|
||||||
|
t.Helper()
|
||||||
|
urlStr := resp["url"].(string)
|
||||||
|
idx := len("http://localhost/static/")
|
||||||
|
fsPath := filepath.Join(".run", "uploads", urlStr[idx:])
|
||||||
|
data, err := os.ReadFile(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read merged file %s: %v", fsPath, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ──
|
||||||
|
|
||||||
|
func TestFullChunkUploadFlow(t *testing.T) {
|
||||||
|
h, cleanup := setupTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
chunkSize := 1024
|
||||||
|
totalChunks := 3
|
||||||
|
chunks, chunkHashes, fileHash := makeTestChunks(t, totalChunks, chunkSize)
|
||||||
|
|
||||||
|
uploadID := initUpload(t, h, "test.mp4", int64(totalChunks*chunkSize), int64(chunkSize), totalChunks, fileHash)
|
||||||
|
|
||||||
|
for i := 0; i < totalChunks; i++ {
|
||||||
|
uploadChunk(t, h, uploadID, i, chunkHashes[i], chunks[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/complete", CompleteChunkUploadRequest{UploadID: uploadID})
|
||||||
|
h.CompleteChunkUpload(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete: expected 200, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseJSON(t, rec)
|
||||||
|
if resp["url"] == nil || resp["play_url"] == nil {
|
||||||
|
t.Fatal("complete response missing url or play_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify merged file content matches original chunks
|
||||||
|
merged := readMergedFile(t, resp)
|
||||||
|
var expected bytes.Buffer
|
||||||
|
for _, ch := range chunks {
|
||||||
|
expected.Write(ch)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(merged, expected.Bytes()) {
|
||||||
|
t.Fatalf("merged file content mismatch: got %d bytes, want %d bytes", len(merged), expected.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify temp dir cleaned up
|
||||||
|
tmpDir := filepath.Join(".run", "uploads", "tmp", uploadID)
|
||||||
|
if _, err := os.Stat(tmpDir); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("temp dir should be removed: %s", tmpDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBreakpointResume(t *testing.T) {
|
||||||
|
h, cleanup := setupTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
chunkSize := 512
|
||||||
|
totalChunks := 4
|
||||||
|
chunks, chunkHashes, fileHash := makeTestChunks(t, totalChunks, chunkSize)
|
||||||
|
|
||||||
|
uploadID := initUpload(t, h, "resume.mp4", int64(totalChunks*chunkSize), int64(chunkSize), totalChunks, fileHash)
|
||||||
|
|
||||||
|
// upload first 2 chunks
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
uploadChunk(t, h, uploadID, i, chunkHashes[i], chunks[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-init with same file_hash → should resume existing session
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/init", InitChunkUploadRequest{
|
||||||
|
Filename: "resume.mp4",
|
||||||
|
FileSize: int64(totalChunks * chunkSize),
|
||||||
|
ChunkSize: int64(chunkSize),
|
||||||
|
TotalChunks: totalChunks,
|
||||||
|
FileHash: fileHash,
|
||||||
|
})
|
||||||
|
h.InitChunkUpload(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("re-init: expected 200, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseJSON(t, rec)
|
||||||
|
|
||||||
|
resumedID := resp["upload_id"].(string)
|
||||||
|
if resumedID != uploadID {
|
||||||
|
t.Fatalf("resume should return same upload_id: got %s, want %s", resumedID, uploadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkList := resp["uploaded_chunks"].([]interface{})
|
||||||
|
if len(chunkList) != 2 {
|
||||||
|
t.Fatalf("expected 2 uploaded chunks on resume, got %d", len(chunkList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload remaining chunks and complete
|
||||||
|
for i := 2; i < totalChunks; i++ {
|
||||||
|
uploadChunk(t, h, uploadID, i, chunkHashes[i], chunks[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = completeUpload(t, h, uploadID)
|
||||||
|
merged := readMergedFile(t, resp)
|
||||||
|
var expected bytes.Buffer
|
||||||
|
for _, ch := range chunks {
|
||||||
|
expected.Write(ch)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(merged, expected.Bytes()) {
|
||||||
|
t.Fatalf("merged file mismatch after resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdempotentChunkUpload(t *testing.T) {
|
||||||
|
h, cleanup := setupTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
chunkSize := 256
|
||||||
|
totalChunks := 2
|
||||||
|
chunks, chunkHashes, fileHash := makeTestChunks(t, totalChunks, chunkSize)
|
||||||
|
|
||||||
|
uploadID := initUpload(t, h, "idempotent.mp4", int64(totalChunks*chunkSize), int64(chunkSize), totalChunks, fileHash)
|
||||||
|
|
||||||
|
// upload chunk 0 twice — both should succeed
|
||||||
|
uploadChunk(t, h, uploadID, 0, chunkHashes[0], chunks[0])
|
||||||
|
uploadChunk(t, h, uploadID, 0, chunkHashes[0], chunks[0])
|
||||||
|
|
||||||
|
uploadChunk(t, h, uploadID, 1, chunkHashes[1], chunks[1])
|
||||||
|
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/complete", CompleteChunkUploadRequest{UploadID: uploadID})
|
||||||
|
h.CompleteChunkUpload(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete: expected 200, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashMismatch(t *testing.T) {
|
||||||
|
h, cleanup := setupTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
chunkSize := 256
|
||||||
|
totalChunks := 1
|
||||||
|
chunks, _, fileHash := makeTestChunks(t, totalChunks, chunkSize)
|
||||||
|
|
||||||
|
uploadID := initUpload(t, h, "hashfail.mp4", int64(chunkSize), int64(chunkSize), totalChunks, fileHash)
|
||||||
|
|
||||||
|
// upload with wrong hash
|
||||||
|
c, rec := newMultipartContext(t, "/video/chunk/upload", map[string]string{
|
||||||
|
"upload_id": uploadID,
|
||||||
|
"chunk_index": "0",
|
||||||
|
"chunk_hash": "deadbeef000000000000000000000000",
|
||||||
|
}, chunks[0])
|
||||||
|
h.UploadChunk(c)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for hash mismatch, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
resp := parseJSON(t, rec)
|
||||||
|
if resp["error"] != "chunk hash mismatch" {
|
||||||
|
t.Fatalf("unexpected error: %v", resp["error"])
|
||||||
|
}
|
||||||
|
if resp["expected"] == nil || resp["actual"] == nil {
|
||||||
|
t.Fatal("response should contain expected and actual hash")
|
||||||
|
}
|
||||||
|
actualHash := computeMD5(chunks[0])
|
||||||
|
if resp["actual"] != actualHash {
|
||||||
|
t.Fatalf("actual hash: got %v, want %s", resp["actual"], actualHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// retry with correct hash should succeed
|
||||||
|
uploadChunk(t, h, uploadID, 0, actualHash, chunks[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncompleteMerge(t *testing.T) {
|
||||||
|
h, cleanup := setupTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
chunkSize := 128
|
||||||
|
totalChunks := 5
|
||||||
|
chunks, chunkHashes, fileHash := makeTestChunks(t, totalChunks, chunkSize)
|
||||||
|
|
||||||
|
uploadID := initUpload(t, h, "incomplete.mp4", int64(totalChunks*chunkSize), int64(chunkSize), totalChunks, fileHash)
|
||||||
|
|
||||||
|
// upload only 3 out of 5
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
uploadChunk(t, h, uploadID, i, chunkHashes[i], chunks[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/complete", CompleteChunkUploadRequest{UploadID: uploadID})
|
||||||
|
h.CompleteChunkUpload(c)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for incomplete, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
resp := parseJSON(t, rec)
|
||||||
|
if resp["error"] != "not all chunks uploaded" {
|
||||||
|
t.Fatalf("unexpected error: %v", resp["error"])
|
||||||
|
}
|
||||||
|
if int(resp["missing"].(float64)) != 2 {
|
||||||
|
t.Fatalf("expected missing=2, got %v", resp["missing"])
|
||||||
|
}
|
||||||
|
if int(resp["completed"].(float64)) != 3 {
|
||||||
|
t.Fatalf("expected completed=3, got %v", resp["completed"])
|
||||||
|
}
|
||||||
|
if int(resp["total"].(float64)) != 5 {
|
||||||
|
t.Fatalf("expected total=5, got %v", resp["total"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload remaining and retry
|
||||||
|
for i := 3; i < totalChunks; i++ {
|
||||||
|
uploadChunk(t, h, uploadID, i, chunkHashes[i], chunks[i])
|
||||||
|
}
|
||||||
|
c, rec = newJSONContext(t, "/video/chunk/complete", CompleteChunkUploadRequest{UploadID: uploadID})
|
||||||
|
h.CompleteChunkUpload(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete after fix: expected 200, got %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkStatus(t *testing.T) {
|
||||||
|
h, cleanup := setupTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
chunkSize := 256
|
||||||
|
totalChunks := 3
|
||||||
|
chunks, chunkHashes, fileHash := makeTestChunks(t, totalChunks, chunkSize)
|
||||||
|
|
||||||
|
uploadID := initUpload(t, h, "status.mp4", int64(totalChunks*chunkSize), int64(chunkSize), totalChunks, fileHash)
|
||||||
|
|
||||||
|
assertStatus := func(wantCount int) {
|
||||||
|
t.Helper()
|
||||||
|
c, rec := newJSONContext(t, "/video/chunk/status", ChunkStatusRequest{UploadID: uploadID})
|
||||||
|
h.ChunkStatus(c)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
resp := parseJSON(t, rec)
|
||||||
|
list, _ := resp["uploaded_chunks"].([]interface{})
|
||||||
|
if len(list) != wantCount {
|
||||||
|
t.Fatalf("expected %d uploaded chunks, got %d", wantCount, len(list))
|
||||||
|
}
|
||||||
|
if int(resp["total_chunks"].(float64)) != totalChunks {
|
||||||
|
t.Fatalf("expected total_chunks=%d", totalChunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertStatus(0)
|
||||||
|
|
||||||
|
uploadChunk(t, h, uploadID, 0, chunkHashes[0], chunks[0])
|
||||||
|
assertStatus(1)
|
||||||
|
|
||||||
|
uploadChunk(t, h, uploadID, 1, chunkHashes[1], chunks[1])
|
||||||
|
uploadChunk(t, h, uploadID, 2, chunkHashes[2], chunks[2])
|
||||||
|
assertStatus(3)
|
||||||
|
}
|
||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"spark-md5": "^3.0.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -1173,6 +1174,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/spark-md5": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/spark-md5/-/spark-md5-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
|
||||||
|
"license": "(WTFPL OR MIT)"
|
||||||
|
},
|
||||||
"node_modules/speakingurl": {
|
"node_modules/speakingurl": {
|
||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"spark-md5": "^3.0.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,30 +1,68 @@
|
|||||||
import { postForm, postJson } from './client'
|
import { postForm, postJson } from './client'
|
||||||
import { normalizeVideoList } from './normalize'
|
import { normalizeVideoList } from './normalize'
|
||||||
import type { Video } from './types'
|
import type { Video } from './types'
|
||||||
|
|
||||||
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
||||||
return postJson<Video>('/video/publish', input, { authRequired: true })
|
return postJson<Video>('/video/publish', input, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadResponse = { url: string; play_url?: string; cover_url?: string }
|
export type UploadResponse = { url: string; play_url?: string; cover_url?: string }
|
||||||
|
|
||||||
export function uploadVideo(file: File) {
|
export function uploadVideo(file: File) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return postForm<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
|
return postForm<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadCover(file: File) {
|
export function uploadCover(file: File) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
|
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listByAuthorId(authorId: number) {
|
export async function listByAuthorId(authorId: number) {
|
||||||
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
|
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
|
||||||
return normalizeVideoList(videos)
|
return normalizeVideoList(videos)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDetail(id: number) {
|
export function getDetail(id: number) {
|
||||||
return postJson<Video>('/video/getDetail', { id })
|
return postJson<Video>('/video/getDetail', { id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chunk Upload API ---
|
||||||
|
|
||||||
|
export type InitChunkUploadResponse = {
|
||||||
|
upload_id: string
|
||||||
|
uploaded_chunks: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initChunkUpload(input: {
|
||||||
|
filename: string
|
||||||
|
file_size: number
|
||||||
|
chunk_size: number
|
||||||
|
total_chunks: number
|
||||||
|
file_hash: string
|
||||||
|
}) {
|
||||||
|
return postJson<InitChunkUploadResponse>('/video/chunk/init', input, { authRequired: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadChunk(uploadId: string, chunkIndex: number, chunkHash: string, blob: Blob) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('upload_id', uploadId)
|
||||||
|
fd.append('chunk_index', String(chunkIndex))
|
||||||
|
fd.append('chunk_hash', chunkHash)
|
||||||
|
fd.append('file', blob)
|
||||||
|
return postForm<{ chunk_index: number }>('/video/chunk/upload', fd, { authRequired: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkStatus(uploadId: string) {
|
||||||
|
return postJson<{ upload_id: string; uploaded_chunks: number[]; total_chunks: number }>(
|
||||||
|
'/video/chunk/status',
|
||||||
|
{ upload_id: uploadId },
|
||||||
|
{ authRequired: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeChunkUpload(uploadId: string) {
|
||||||
|
return postJson<UploadResponse>('/video/chunk/complete', { upload_id: uploadId }, { authRequired: true })
|
||||||
|
}
|
||||||
|
|||||||
33
frontend/src/types/spark-md5.d.ts
vendored
Normal file
33
frontend/src/types/spark-md5.d.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
declare module 'spark-md5' {
|
||||||
|
class SparkMD5 {
|
||||||
|
append(str: string): SparkMD5
|
||||||
|
end(raw?: boolean): string
|
||||||
|
reset(): SparkMD5
|
||||||
|
getState(): SparkMD5.State
|
||||||
|
setState(state: SparkMD5.State): SparkMD5
|
||||||
|
destroy(): void
|
||||||
|
static hash(str: string, raw?: boolean): string
|
||||||
|
static hashArray(arr: ArrayLike<number>, raw?: boolean): string
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace SparkMD5 {
|
||||||
|
interface State {
|
||||||
|
buff: Uint8Array
|
||||||
|
length: number
|
||||||
|
hash: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArrayBuffer {
|
||||||
|
append(arr: globalThis.ArrayBuffer): ArrayBuffer
|
||||||
|
end(raw?: boolean): string
|
||||||
|
reset(): ArrayBuffer
|
||||||
|
getState(): State
|
||||||
|
setState(state: State): ArrayBuffer
|
||||||
|
destroy(): void
|
||||||
|
static hash(arr: globalThis.ArrayBuffer, raw?: boolean): string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SparkMD5
|
||||||
|
export { SparkMD5 }
|
||||||
|
}
|
||||||
863
frontend/src/views/VideoView.vue
vendored
863
frontend/src/views/VideoView.vue
vendored
@@ -1,346 +1,517 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted, reactive, ref, watch } from 'vue'
|
import { onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as videoApi from '../api/video'
|
import * as videoApi from '../api/video'
|
||||||
import type { Video } from '../api/types'
|
import type { Video } from '../api/types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
import SparkMD5 from 'spark-md5'
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
const router = useRouter()
|
||||||
const toast = useToastStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
const busy = ref(false)
|
|
||||||
const stage = ref('')
|
const busy = ref(false)
|
||||||
const published = ref<Video | null>(null)
|
const stage = ref('')
|
||||||
|
const published = ref<Video | null>(null)
|
||||||
const videoInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const coverInput = ref<HTMLInputElement | null>(null)
|
const videoInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const coverInput = ref<HTMLInputElement | null>(null)
|
||||||
const publishForm = reactive({
|
|
||||||
title: '',
|
const publishForm = reactive({
|
||||||
description: '',
|
title: '',
|
||||||
video: null as File | null,
|
description: '',
|
||||||
cover: null as File | null,
|
video: null as File | null,
|
||||||
})
|
cover: null as File | null,
|
||||||
|
})
|
||||||
const preview = reactive({
|
|
||||||
videoUrl: '',
|
const preview = reactive({
|
||||||
coverUrl: '',
|
videoUrl: '',
|
||||||
})
|
coverUrl: '',
|
||||||
|
})
|
||||||
function setPreviewVideo(file: File | null) {
|
|
||||||
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
|
// chunk upload progress
|
||||||
preview.videoUrl = file ? URL.createObjectURL(file) : ''
|
const uploadProgress = reactive({
|
||||||
}
|
uploadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
function setPreviewCover(file: File | null) {
|
percent: 0,
|
||||||
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
|
})
|
||||||
preview.coverUrl = file ? URL.createObjectURL(file) : ''
|
|
||||||
}
|
function setPreviewVideo(file: File | null) {
|
||||||
|
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
|
||||||
watch(
|
preview.videoUrl = file ? URL.createObjectURL(file) : ''
|
||||||
() => publishForm.video,
|
}
|
||||||
(f) => setPreviewVideo(f),
|
|
||||||
)
|
function setPreviewCover(file: File | null) {
|
||||||
|
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
|
||||||
watch(
|
preview.coverUrl = file ? URL.createObjectURL(file) : ''
|
||||||
() => publishForm.cover,
|
}
|
||||||
(f) => setPreviewCover(f),
|
|
||||||
)
|
watch(
|
||||||
|
() => publishForm.video,
|
||||||
onUnmounted(() => {
|
(f) => setPreviewVideo(f),
|
||||||
setPreviewVideo(null)
|
)
|
||||||
setPreviewCover(null)
|
|
||||||
})
|
watch(
|
||||||
|
() => publishForm.cover,
|
||||||
function pickVideo(e: Event) {
|
(f) => setPreviewCover(f),
|
||||||
const input = e.target as HTMLInputElement
|
)
|
||||||
publishForm.video = input.files?.[0] ?? null
|
|
||||||
}
|
onUnmounted(() => {
|
||||||
|
setPreviewVideo(null)
|
||||||
function pickCover(e: Event) {
|
setPreviewCover(null)
|
||||||
const input = e.target as HTMLInputElement
|
})
|
||||||
publishForm.cover = input.files?.[0] ?? null
|
|
||||||
}
|
function pickVideo(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
function openVideoPicker() {
|
publishForm.video = input.files?.[0] ?? null
|
||||||
videoInput.value?.click()
|
}
|
||||||
}
|
|
||||||
|
function pickCover(e: Event) {
|
||||||
function openCoverPicker() {
|
const input = e.target as HTMLInputElement
|
||||||
coverInput.value?.click()
|
publishForm.cover = input.files?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearVideo() {
|
function openVideoPicker() {
|
||||||
publishForm.video = null
|
videoInput.value?.click()
|
||||||
if (videoInput.value) videoInput.value.value = ''
|
}
|
||||||
}
|
|
||||||
|
function openCoverPicker() {
|
||||||
function clearCover() {
|
coverInput.value?.click()
|
||||||
publishForm.cover = null
|
}
|
||||||
if (coverInput.value) coverInput.value.value = ''
|
|
||||||
}
|
function clearVideo() {
|
||||||
|
publishForm.video = null
|
||||||
async function onPublish() {
|
if (videoInput.value) videoInput.value.value = ''
|
||||||
if (busy.value) return
|
}
|
||||||
if (!auth.isLoggedIn) {
|
|
||||||
toast.error('请先登录')
|
function clearCover() {
|
||||||
await router.push('/account')
|
publishForm.cover = null
|
||||||
return
|
if (coverInput.value) coverInput.value.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = publishForm.title.trim()
|
function resetProgress() {
|
||||||
const description = publishForm.description.trim()
|
uploadProgress.uploadedBytes = 0
|
||||||
if (!title) {
|
uploadProgress.totalBytes = 0
|
||||||
toast.error('请输入 title')
|
uploadProgress.percent = 0
|
||||||
return
|
}
|
||||||
}
|
|
||||||
if (!publishForm.video) {
|
// Compute file md5 by reading in 2MB chunks
|
||||||
toast.error('请选择视频文件(.mp4)')
|
async function computeFileMD5(file: File): Promise<string> {
|
||||||
return
|
const chunkSize = 2 << 20
|
||||||
}
|
const spark = new SparkMD5.ArrayBuffer()
|
||||||
if (!publishForm.cover) {
|
for (let offset = 0; offset < file.size; offset += chunkSize) {
|
||||||
toast.error('请选择封面图片(jpg/png/webp)')
|
const end = Math.min(offset + chunkSize, file.size)
|
||||||
return
|
const buf = await file.slice(offset, end).arrayBuffer()
|
||||||
}
|
spark.append(buf)
|
||||||
|
}
|
||||||
busy.value = true
|
return spark.end()
|
||||||
stage.value = ''
|
}
|
||||||
published.value = null
|
|
||||||
try {
|
// Compute md5 for a single chunk blob
|
||||||
stage.value = '上传封面'
|
async function computeChunkMD5(blob: Blob): Promise<string> {
|
||||||
const coverRes = await videoApi.uploadCover(publishForm.cover!)
|
const buf = await blob.arrayBuffer()
|
||||||
|
const spark = new SparkMD5.ArrayBuffer()
|
||||||
stage.value = '上传视频'
|
spark.append(buf)
|
||||||
const videoRes = await videoApi.uploadVideo(publishForm.video!)
|
return spark.end()
|
||||||
|
}
|
||||||
const coverUrl = coverRes.url || coverRes.cover_url || ''
|
|
||||||
const playUrl = videoRes.url || videoRes.play_url || ''
|
const CHUNK_SIZE = 5 << 20 // 5 MB
|
||||||
if (!coverUrl || !playUrl) {
|
const MAX_CONCURRENT = 3
|
||||||
toast.error('上传成功但缺少 url')
|
const MAX_RETRIES = 3
|
||||||
return
|
|
||||||
}
|
async function uploadVideoChunked(file: File): Promise<videoApi.UploadResponse> {
|
||||||
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
|
||||||
stage.value = '发布视频'
|
const fileHash = await computeFileMD5(file)
|
||||||
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
|
|
||||||
|
stage.value = '初始化上传'
|
||||||
published.value = res
|
const initRes = await videoApi.initChunkUpload({
|
||||||
toast.success('已发布')
|
filename: file.name,
|
||||||
|
file_size: file.size,
|
||||||
publishForm.title = ''
|
chunk_size: CHUNK_SIZE,
|
||||||
publishForm.description = ''
|
total_chunks: totalChunks,
|
||||||
clearVideo()
|
file_hash: fileHash,
|
||||||
clearCover()
|
})
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const uploadId = initRes.upload_id
|
||||||
toast.error(msg)
|
const uploadedSet = new Set(initRes.uploaded_chunks)
|
||||||
} finally {
|
|
||||||
busy.value = false
|
uploadProgress.totalBytes = file.size
|
||||||
stage.value = ''
|
uploadProgress.uploadedBytes = uploadedSet.size * CHUNK_SIZE
|
||||||
}
|
// Last chunk might be smaller
|
||||||
}
|
if (uploadedSet.has(totalChunks - 1)) {
|
||||||
</script>
|
uploadProgress.uploadedBytes -= CHUNK_SIZE
|
||||||
|
uploadProgress.uploadedBytes += file.size - (totalChunks - 1) * CHUNK_SIZE
|
||||||
<template>
|
}
|
||||||
<AppShell>
|
uploadProgress.percent = uploadProgress.totalBytes > 0
|
||||||
<div class="publish-wrap">
|
? Math.round((uploadProgress.uploadedBytes / uploadProgress.totalBytes) * 100)
|
||||||
<div class="card publish-card">
|
: 0
|
||||||
<div class="row" style="justify-content: space-between; align-items: baseline">
|
|
||||||
<p class="title" style="margin: 0">发布视频</p>
|
// Build list of chunks that still need uploading
|
||||||
<div v-if="busy" class="pill">进行中:{{ stage || '…' }}</div>
|
const pending: number[] = []
|
||||||
</div>
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。</p>
|
if (!uploadedSet.has(i)) {
|
||||||
|
pending.push(i)
|
||||||
<div class="grid form-grid" style="margin-top: 16px">
|
}
|
||||||
<div>
|
}
|
||||||
<label>title</label>
|
|
||||||
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
|
if (pending.length === 0) {
|
||||||
</div>
|
stage.value = '合并文件'
|
||||||
<div>
|
return videoApi.completeChunkUpload(uploadId)
|
||||||
<label>description</label>
|
}
|
||||||
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
|
|
||||||
</div>
|
stage.value = '上传视频'
|
||||||
<div class="grid two">
|
|
||||||
<div>
|
// Upload chunks with concurrency limit
|
||||||
<label>video (.mp4)</label>
|
let idx = 0
|
||||||
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
|
const advanceProgress = (chunkIndex: number) => {
|
||||||
<div class="file-box">
|
const chunkBytes = chunkIndex === totalChunks - 1
|
||||||
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
|
? file.size - chunkIndex * CHUNK_SIZE
|
||||||
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
|
: CHUNK_SIZE
|
||||||
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
|
uploadProgress.uploadedBytes += chunkBytes
|
||||||
</div>
|
uploadProgress.percent = Math.round((uploadProgress.uploadedBytes / uploadProgress.totalBytes) * 100)
|
||||||
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
|
}
|
||||||
</div>
|
|
||||||
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
|
const uploadOne = async (chunkIndex: number): Promise<void> => {
|
||||||
已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB)
|
const start = chunkIndex * CHUNK_SIZE
|
||||||
</div>
|
const end = Math.min(start + CHUNK_SIZE, file.size)
|
||||||
</div>
|
const blob = file.slice(start, end)
|
||||||
<div>
|
const chunkHash = await computeChunkMD5(blob)
|
||||||
<label>cover (jpg/png/webp)</label>
|
|
||||||
<input
|
let lastErr: unknown
|
||||||
ref="coverInput"
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
class="file-native"
|
try {
|
||||||
type="file"
|
await videoApi.uploadChunk(uploadId, chunkIndex, chunkHash, blob)
|
||||||
accept="image/jpeg,image/png,image/webp"
|
advanceProgress(chunkIndex)
|
||||||
:disabled="busy"
|
return
|
||||||
@change="pickCover"
|
} catch (e) {
|
||||||
/>
|
lastErr = e
|
||||||
<div class="file-box">
|
}
|
||||||
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
|
}
|
||||||
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
|
throw lastErr
|
||||||
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
|
}
|
||||||
</div>
|
|
||||||
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
|
await new Promise<void>((resolve, reject) => {
|
||||||
</div>
|
let active = 0
|
||||||
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择:{{ publishForm.cover.name }}</div>
|
let done = false
|
||||||
</div>
|
|
||||||
</div>
|
const next = () => {
|
||||||
|
if (done) return
|
||||||
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
|
if (idx >= pending.length && active === 0) {
|
||||||
<div v-if="preview.coverUrl" class="preview-card">
|
resolve()
|
||||||
<div class="subtle">封面预览</div>
|
return
|
||||||
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
|
}
|
||||||
</div>
|
while (active < MAX_CONCURRENT && idx < pending.length) {
|
||||||
<div v-if="preview.videoUrl" class="preview-card">
|
const ci = pending[idx++] as number
|
||||||
<div class="subtle">视频预览</div>
|
active++
|
||||||
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
|
uploadOne(ci)
|
||||||
</div>
|
.then(() => { active--; next() })
|
||||||
</div>
|
.catch((e) => { done = true; reject(e) })
|
||||||
|
}
|
||||||
<div class="row" style="justify-content: flex-end; margin-top: 8px">
|
}
|
||||||
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
|
next()
|
||||||
</div>
|
})
|
||||||
</div>
|
|
||||||
|
stage.value = '合并文件'
|
||||||
<div v-if="published" class="card" style="margin-top: 14px">
|
return videoApi.completeChunkUpload(uploadId)
|
||||||
<p class="title">已发布</p>
|
}
|
||||||
<div class="row" style="justify-content: space-between">
|
|
||||||
<div>
|
async function onPublish() {
|
||||||
<div class="title" style="margin: 0">{{ published.title }}</div>
|
if (busy.value) return
|
||||||
<div class="subtle mono">#{{ published.id }}</div>
|
if (!auth.isLoggedIn) {
|
||||||
</div>
|
toast.error('请先登录')
|
||||||
<div class="row">
|
await router.push('/account')
|
||||||
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
|
return
|
||||||
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
|
}
|
||||||
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
|
||||||
</div>
|
const title = publishForm.title.trim()
|
||||||
</div>
|
const description = publishForm.description.trim()
|
||||||
</div>
|
if (!title) {
|
||||||
</div>
|
toast.error('请输入 title')
|
||||||
</div>
|
return
|
||||||
</AppShell>
|
}
|
||||||
</template>
|
if (!publishForm.video) {
|
||||||
|
toast.error('请选择视频文件(.mp4)')
|
||||||
<style scoped>
|
return
|
||||||
.publish-wrap {
|
}
|
||||||
display: grid;
|
if (!publishForm.cover) {
|
||||||
justify-items: center;
|
toast.error('请选择封面图片(jpg/png/webp)')
|
||||||
}
|
return
|
||||||
|
}
|
||||||
.publish-card {
|
|
||||||
width: min(980px, 100%);
|
busy.value = true
|
||||||
padding: 22px;
|
stage.value = ''
|
||||||
}
|
published.value = null
|
||||||
|
resetProgress()
|
||||||
.form-grid {
|
try {
|
||||||
gap: 16px;
|
const videoRes = await uploadVideoChunked(publishForm.video!)
|
||||||
}
|
|
||||||
|
stage.value = '上传封面'
|
||||||
.form-grid .grid.two {
|
const coverRes = await videoApi.uploadCover(publishForm.cover!)
|
||||||
gap: 20px;
|
|
||||||
}
|
const coverUrl = coverRes.url || coverRes.cover_url || ''
|
||||||
|
const playUrl = videoRes.url || videoRes.play_url || ''
|
||||||
.form-grid .grid.two > * {
|
if (!coverUrl || !playUrl) {
|
||||||
min-width: 0;
|
toast.error('上传成功但缺少 url')
|
||||||
}
|
return
|
||||||
|
}
|
||||||
.form-grid input[type='file'] {
|
|
||||||
max-width: 100%;
|
stage.value = '发布视频'
|
||||||
}
|
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
|
||||||
|
|
||||||
.file-native {
|
published.value = res
|
||||||
position: absolute;
|
toast.success('已发布')
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
publishForm.title = ''
|
||||||
padding: 0;
|
publishForm.description = ''
|
||||||
margin: -1px;
|
clearVideo()
|
||||||
overflow: hidden;
|
clearCover()
|
||||||
clip: rect(0, 0, 0, 0);
|
} catch (e) {
|
||||||
white-space: nowrap;
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
border: 0;
|
toast.error(msg)
|
||||||
}
|
} finally {
|
||||||
|
busy.value = false
|
||||||
.file-box {
|
stage.value = ''
|
||||||
display: flex;
|
resetProgress()
|
||||||
align-items: center;
|
}
|
||||||
gap: 10px;
|
}
|
||||||
padding: 10px 12px;
|
</script>
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
<template>
|
||||||
border-radius: 14px;
|
<AppShell>
|
||||||
min-height: 46px;
|
<div class="publish-wrap">
|
||||||
}
|
<div class="card publish-card">
|
||||||
|
<div class="row" style="justify-content: space-between; align-items: baseline">
|
||||||
.file-box button {
|
<p class="title" style="margin: 0">发布视频</p>
|
||||||
padding: 8px 10px;
|
<div v-if="busy" class="pill">进行中:{{ stage || '…' }}</div>
|
||||||
border-radius: 12px;
|
</div>
|
||||||
}
|
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。</p>
|
||||||
|
|
||||||
.file-name {
|
<div class="grid form-grid" style="margin-top: 16px">
|
||||||
flex: 1;
|
<div>
|
||||||
min-width: 0;
|
<label>title</label>
|
||||||
overflow: hidden;
|
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
|
||||||
text-overflow: ellipsis;
|
</div>
|
||||||
white-space: nowrap;
|
<div>
|
||||||
font-size: 13px;
|
<label>description</label>
|
||||||
color: rgba(255, 255, 255, 0.88);
|
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
|
||||||
}
|
</div>
|
||||||
|
<div class="grid two">
|
||||||
.muted {
|
<div>
|
||||||
color: rgba(255, 255, 255, 0.55);
|
<label>video (.mp4)</label>
|
||||||
}
|
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
|
||||||
|
<div class="file-box">
|
||||||
.big-input {
|
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
|
||||||
box-sizing: border-box;
|
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
|
||||||
width: 100%;
|
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
|
||||||
max-width: 100%;
|
</div>
|
||||||
padding: 12px 14px;
|
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
|
||||||
font-size: 14px;
|
</div>
|
||||||
border-radius: 14px;
|
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
|
||||||
}
|
已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB)
|
||||||
|
</div>
|
||||||
.big-btn {
|
</div>
|
||||||
padding: 12px 18px;
|
<div>
|
||||||
font-size: 14px;
|
<label>cover (jpg/png/webp)</label>
|
||||||
border-radius: 14px;
|
<input
|
||||||
}
|
ref="coverInput"
|
||||||
|
class="file-native"
|
||||||
.preview-card {
|
type="file"
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
accept="image/jpeg,image/png,image/webp"
|
||||||
background: rgba(255, 255, 255, 0.05);
|
:disabled="busy"
|
||||||
border-radius: 16px;
|
@change="pickCover"
|
||||||
padding: 12px;
|
/>
|
||||||
display: grid;
|
<div class="file-box">
|
||||||
gap: 10px;
|
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
|
||||||
}
|
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
|
||||||
|
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
|
||||||
.cover {
|
</div>
|
||||||
width: 100%;
|
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
|
||||||
aspect-ratio: 9/12;
|
</div>
|
||||||
object-fit: cover;
|
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择:{{ publishForm.cover.name }}</div>
|
||||||
border-radius: 14px;
|
</div>
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
</div>
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
<!-- Upload progress bar -->
|
||||||
|
<div v-if="busy && uploadProgress.totalBytes > 0" class="progress-wrap">
|
||||||
.video {
|
<div class="progress-bar">
|
||||||
width: 100%;
|
<div class="progress-fill" :style="{ width: uploadProgress.percent + '%' }"></div>
|
||||||
border-radius: 14px;
|
</div>
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
<div class="progress-text">
|
||||||
background: rgba(0, 0, 0, 0.35);
|
{{ (uploadProgress.uploadedBytes / 1024 / 1024).toFixed(1) }} MB /
|
||||||
}
|
{{ (uploadProgress.totalBytes / 1024 / 1024).toFixed(1) }} MB
|
||||||
</style>
|
({{ uploadProgress.percent }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
|
||||||
|
<div v-if="preview.videoUrl" class="preview-card">
|
||||||
|
<div class="subtle">视频预览</div>
|
||||||
|
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
|
||||||
|
</div>
|
||||||
|
<div v-if="preview.coverUrl" class="preview-card">
|
||||||
|
<div class="subtle">封面预览</div>
|
||||||
|
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="justify-content: flex-end; margin-top: 8px">
|
||||||
|
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="published" class="card" style="margin-top: 14px">
|
||||||
|
<p class="title">已发布</p>
|
||||||
|
<div class="row" style="justify-content: space-between">
|
||||||
|
<div>
|
||||||
|
<div class="title" style="margin: 0">{{ published.title }}</div>
|
||||||
|
<div class="subtle mono">#{{ published.id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
|
||||||
|
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
|
||||||
|
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.publish-wrap {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-card {
|
||||||
|
width: min(980px, 100%);
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .grid.two {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .grid.two > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input[type='file'] {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-native {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
min-height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-box button {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-btn {
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 9/12;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #4a9eff;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user