feat(server): 添加缓存系统和视频流处理功能 - 初始化512MB内存缓存系统,TTL设置为60分钟 - 添加支持Range请求的视频流处理器,优化大文件加载性能 - 使用gin.Recovery()增强服务稳定性 - 将静态文件服务替换为专门的视频流处理器 fix(config): 更新服务器主机地址 - 将Host地址从192.168.124.2更新为192.168.124.8 ```
256 lines
6.6 KiB
Go
256 lines
6.6 KiB
Go
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)
|
||
}
|