x-movie/server/video_handler.go
eson afc68bf861 ```
feat(server): 添加缓存系统和视频流处理功能

- 初始化512MB内存缓存系统,TTL设置为60分钟
- 添加支持Range请求的视频流处理器,优化大文件加载性能
- 使用gin.Recovery()增强服务稳定性
- 将静态文件服务替换为专门的视频流处理器

fix(config): 更新服务器主机地址

- 将Host地址从192.168.124.2更新为192.168.124.8
```
2025-12-29 06:22:19 +08:00

256 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// 全局缩略图缓存
var thumbnailCache *ThumbnailCache
// StreamVideo 处理视频流式传输,支持 HTTP Range 请求
func StreamVideo(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"})
return
}
// 清理路径,防止目录遍历攻击
filename = filepath.Base(filename)
filePath := filepath.Join("movie", filename)
// 检查是否是缩略图PNG文件
if strings.HasSuffix(strings.ToLower(filename), ".png") {
serveThumbnail(c, filePath, filename)
return
}
// 检查文件是否存在
fileInfo, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to access file"})
return
}
// 打开文件
file, err := os.Open(filePath)
if err != nil {
log.Printf("Failed to open file: %s, error: %v", filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
return
}
defer file.Close()
// 获取文件大小
fileSize := fileInfo.Size()
// 检查是否支持 Range 请求
rangeHeader := c.GetHeader("Range")
if rangeHeader == "" {
// 不支持 Range 请求,发送整个文件
c.Header("Content-Type", getContentType(filePath))
c.Header("Content-Length", strconv.FormatInt(fileSize, 10))
c.Header("Accept-Ranges", "bytes")
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.File(filePath)
return
}
// 解析 Range 头部 (例如: "bytes=0-1023")
ranges, err := parseRange(rangeHeader, fileSize)
if err != nil {
c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize))
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"})
return
}
// 使用第一个 Range简化处理只支持单个 Range
r := ranges[0]
// 设置响应头优化添加Gzip压缩和CDN友好头
c.Header("Content-Type", getContentType(filePath))
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.end-1, fileSize))
c.Header("Content-Length", strconv.FormatInt(r.end-r.start, 10))
c.Header("Accept-Ranges", "bytes")
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
c.Header("X-Content-Type-Options", "nosniff") // 安全头
// 定位到指定位置
_, err = file.Seek(r.start, 0)
if err != nil {
log.Printf("Failed to seek file: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek file"})
return
}
// 流式传输文件内容
c.Status(http.StatusPartialContent)
_, err = io.CopyN(c.Writer, file, r.end-r.start)
if err != nil {
log.Printf("Error streaming file: %v", err)
}
}
// Range 表示一个字节范围
type Range struct {
start int64
end int64
}
// parseRange 解析 Range 头部
func parseRange(rangeHeader string, fileSize int64) ([]Range, error) {
// Range 格式: "bytes=start-end"
if !strings.HasPrefix(rangeHeader, "bytes=") {
return nil, fmt.Errorf("invalid range format")
}
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
parts := strings.Split(rangeSpec, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid range format")
}
var start, end int64
var err error
// 解析起始位置
if parts[0] != "" {
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid start position")
}
} else {
// 如果没有指定起始位置,则从文件末尾开始计算
// 例如: "bytes=-500" 表示最后500字节
if parts[1] != "" {
suffixLength, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid suffix length")
}
start = fileSize - suffixLength
if start < 0 {
start = 0
}
end = fileSize
return []Range{{start: start, end: end}}, nil
}
start = 0
}
// 解析结束位置
if parts[1] != "" {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid end position")
}
// Range 包含结束位置,所以需要 +1
end++
} else {
// 如果没有指定结束位置,则到文件末尾
end = fileSize
}
// 验证范围有效性
if start < 0 || end > fileSize || start >= end {
return nil, fmt.Errorf("invalid range")
}
return []Range{{start: start, end: end}}, nil
}
// getContentType 根据文件扩展名获取 Content-Type
func getContentType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".ogg":
return "video/ogg"
case ".mov":
return "video/quicktime"
case ".avi":
return "video/x-msvideo"
case ".mkv":
return "video/x-matroska"
case ".m4v":
return "video/mp4"
default:
return "application/octet-stream"
}
}
// serveThumbnail 服务缩略图,使用缓存优化
func serveThumbnail(c *gin.Context, filePath string, filename string) {
// 首先尝试从缓存获取
if thumbnailCache != nil {
if cachedData, found := thumbnailCache.Get(filename); found {
c.Data(http.StatusOK, "image/png", cachedData)
return
}
}
// 缓存未命中,从磁盘读取
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "thumbnail not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open thumbnail"})
return
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get file info"})
return
}
fileSize := fileInfo.Size()
// 读取文件内容
data := make([]byte, fileSize)
_, err = io.ReadFull(file, data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read thumbnail"})
return
}
// 存入缓存
if thumbnailCache != nil {
thumbnailCache.Set(filename, data)
}
// 设置响应头
c.Header("Content-Type", "image/png")
c.Header("Content-Length", strconv.FormatInt(fileSize, 10))
c.Header("Cache-Control", "public, max-age=86400, immutable") // 缩略图缓存1天
c.Header("X-Content-Type-Options", "nosniff")
// 发送数据
c.Data(http.StatusOK, "image/png", data)
}
// InitCache 初始化缓存系统
func InitCache(maxSizeMB int, ttlMinutes int) {
thumbnailCache = NewThumbnailCache(maxSizeMB, ttlMinutes)
log.Printf("缓存系统已初始化,最大大小: %dMB, TTL: %d分钟", maxSizeMB, ttlMinutes)
}