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) }