mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
优化
This commit is contained in:
@@ -22,6 +22,10 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
registerAdminStrmRoutes(admin, svc)
|
||||
registerAdminScraperRoutes(admin, svc)
|
||||
registerAdminDatabaseRoutes(admin, svc)
|
||||
|
||||
// FFmpeg/FFprobe 工具:状态查询 + 一键下载安装(自动匹配当前平台)。
|
||||
admin.GET("/tools/ffmpeg/status", ffToolsStatusHandler(svc))
|
||||
admin.POST("/tools/ffmpeg/install", ffToolsInstallHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
|
||||
@@ -39,6 +39,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
|
||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
|
||||
@@ -128,3 +128,19 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
items, err := svc.Media.ListMediaEpisodes(c.Request.Context(), id, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/vtt; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
if err := svc.Subtitle.Serve(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// Package handler — system tools detection.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// SystemHandler handles system-related endpoints.
|
||||
type SystemHandler struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
svc *service.Container
|
||||
}
|
||||
|
||||
// NewSystemHandler is the constructor.
|
||||
func NewSystemHandler(cfg *config.Config, log *zap.Logger, svc *service.Container) *SystemHandler {
|
||||
return &SystemHandler{cfg: cfg, log: log, svc: svc}
|
||||
}
|
||||
|
||||
// ToolStatus represents the detection status of a system tool.
|
||||
type ToolStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Detected bool `json:"detected"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// GetToolsStatus returns the status of system tools.
|
||||
func (h *SystemHandler) GetToolsStatus(c *gin.Context) {
|
||||
tools := []ToolStatus{
|
||||
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||
}
|
||||
|
||||
for i := range tools {
|
||||
// Check configured path first
|
||||
var configuredPath string
|
||||
switch tools[i].ConfigKey {
|
||||
case "app.ffprobe_path":
|
||||
configuredPath = h.cfg.App.FFprobePath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffprobe"
|
||||
}
|
||||
case "app.ffmpeg_path":
|
||||
configuredPath = h.cfg.App.FFmpegPath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the tool
|
||||
path, err := exec.LookPath(configuredPath)
|
||||
if err == nil {
|
||||
tools[i].Detected = true
|
||||
tools[i].Path = path
|
||||
// Try to get version
|
||||
tools[i].Version = getToolVersion(path)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tools": tools,
|
||||
})
|
||||
}
|
||||
|
||||
// getToolVersion attempts to get the version of a tool.
|
||||
func getToolVersion(path string) string {
|
||||
out, err := exec.Command(path, "-version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract first line as version info
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if len(lines) > 0 {
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallTools attempts to auto-install system tools (ffmpeg/ffprobe)
|
||||
func (h *SystemHandler) InstallTools(c *gin.Context) {
|
||||
h.log.Info("Received tools auto-install request")
|
||||
|
||||
// Call service layer to auto-install
|
||||
ffprobePath, ffmpegPath := service.AutoInstallFFmpeg(h.log, h.cfg)
|
||||
|
||||
result := gin.H{
|
||||
"installed": ffprobePath != "" || ffmpegPath != "",
|
||||
}
|
||||
|
||||
if ffprobePath != "" {
|
||||
result["ffprobe_path"] = ffprobePath
|
||||
result["ffprobe_installed"] = true
|
||||
}
|
||||
if ffmpegPath != "" {
|
||||
result["ffmpeg_path"] = ffmpegPath
|
||||
result["ffmpeg_installed"] = true
|
||||
}
|
||||
|
||||
// Re-detect tool status
|
||||
tools := []ToolStatus{
|
||||
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||
}
|
||||
|
||||
for i := range tools {
|
||||
var configuredPath string
|
||||
switch tools[i].ConfigKey {
|
||||
case "app.ffprobe_path":
|
||||
configuredPath = h.cfg.App.FFprobePath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffprobe"
|
||||
}
|
||||
case "app.ffmpeg_path":
|
||||
configuredPath = h.cfg.App.FFmpegPath
|
||||
if configuredPath == "" {
|
||||
configuredPath = "ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(configuredPath)
|
||||
if err == nil {
|
||||
tools[i].Detected = true
|
||||
tools[i].Path = path
|
||||
tools[i].Version = getToolVersion(path)
|
||||
}
|
||||
}
|
||||
|
||||
result["tools"] = tools
|
||||
|
||||
h.log.Info("Tool installation completed", zap.Any("result", result))
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package handler — FFmpeg/FFprobe 工具安装端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// ffToolsStatusHandler 返回 ffmpeg/ffprobe 当前安装状态
|
||||
// (GET /api/admin/tools/ffmpeg/status)。
|
||||
func ffToolsStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.FFTools == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
// ffToolsInstallHandler 触发后台下载安装(POST /api/admin/tools/ffmpeg/install)。
|
||||
// 自动匹配当前运行环境(OS+架构),安装到 data/tools/ffmpeg/ 并把路径写入设置。
|
||||
func ffToolsInstallHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.FFTools == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
|
||||
return
|
||||
}
|
||||
if err := svc.FFTools.StartInstall(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,9 @@ func (e *EmbyService) appendSubtitleStreams(ctx context.Context, streams []map[s
|
||||
if e == nil || e.subtitle == nil || m == nil {
|
||||
return streams
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
// Emby 字幕只列表外挂字幕文件:云盘/strm 媒体的容器内嵌字幕不做服务端
|
||||
// 提取,客户端直连播放直链时自行解析。
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return streams
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
// so the DeliveryUrl advertised in MediaStreams lines up exactly with the
|
||||
// served track: subtitles start at 1 when no audio stream is present, otherwise
|
||||
// at 2 (after Video 0 + Audio 1).
|
||||
//
|
||||
// 只服务外挂字幕文件(DiscoverExternalOnly):云盘/strm 媒体的容器内嵌字幕
|
||||
// 不做服务端提取,客户端直连播放直链时自行解析。
|
||||
func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, mediaID, indexStr string, userID string) error {
|
||||
if e == nil || e.subtitle == nil {
|
||||
return ErrSubtitleUnavailable
|
||||
@@ -44,7 +47,7 @@ func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, medi
|
||||
if err != nil || index < 1 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
@@ -74,7 +77,7 @@ func (e *EmbyService) SubtitleStreamCodec(ctx context.Context, mediaID, indexStr
|
||||
if err != nil || index < 1 {
|
||||
return ""
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,196 +1,358 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
// AutoInstallFFmpeg is only called by the admin tool-install endpoint. The
|
||||
// server must not auto-download or keep ffmpeg/ffprobe running during startup.
|
||||
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
ffprobePath = path
|
||||
cfg.App.FFprobePath = path
|
||||
log.Info("found local ffprobe", zap.String("path", path))
|
||||
}
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFmpegPath, "ffmpeg"); err == nil {
|
||||
ffmpegPath = path
|
||||
cfg.App.FFmpegPath = path
|
||||
log.Info("found local ffmpeg", zap.String("path", path))
|
||||
}
|
||||
if ffprobePath != "" || ffmpegPath != "" {
|
||||
return ffprobePath, ffmpegPath
|
||||
}
|
||||
// ffmpegDownloadTarget 描述某个平台对应的官方构建下载源。
|
||||
type ffmpegDownloadTarget struct {
|
||||
Label string // 展示名,如 "Windows x86_64"
|
||||
Kind string // 压缩包类型:zip / tar.xz
|
||||
Archives []string // 依次尝试的下载地址(主源 + 备用源)
|
||||
}
|
||||
|
||||
// 2. 检查默认安装位置。
|
||||
defaultDir := getDefaultInstallDir()
|
||||
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
|
||||
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
|
||||
|
||||
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||
log.Info("在默认位置找到 ffprobe", zap.String("path", ffprobeDefault))
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
}
|
||||
|
||||
// 3. 尝试自动安装。
|
||||
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
|
||||
installed, err := tryAutoInstall(log, defaultDir)
|
||||
if err != nil {
|
||||
log.Error("自动安装失败,请手动安装 ffmpeg", zap.Error(err))
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if installed {
|
||||
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||
log.Info("自动安装成功", zap.String("path", ffprobeDefault))
|
||||
// 更新配置
|
||||
updateConfigPaths(cfg, ffprobeDefault, ffmpegDefault)
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
// ffmpegTargetForPlatform 按当前运行环境(OS+架构)选择下载源。
|
||||
func ffmpegTargetForPlatform() (*ffmpegDownloadTarget, error) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Windows x86_64",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
case "386":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Windows x86",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win32-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux x86_64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
|
||||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "arm64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux ARM64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz",
|
||||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "arm":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux ARM (32 位)",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarmhf-gpl.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
case "loong64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "Linux LoongArch64",
|
||||
Kind: "tar.xz",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxloongarch64-gpl.tar.xz",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case "darwin":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "macOS x86_64",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-osx64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
case "arm64":
|
||||
return &ffmpegDownloadTarget{
|
||||
Label: "macOS Apple Silicon",
|
||||
Kind: "zip",
|
||||
Archives: []string{
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-osxarm64-gpl.zip",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
return nil, fmt.Errorf("暂不支持自动下载的平台 %s/%s,请手动填写 ffmpeg/ffprobe 路径", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// getDefaultInstallDir 返回默认安装目录
|
||||
func getDefaultInstallDir() string {
|
||||
exePath, err := os.Executable()
|
||||
// installFFmpegTools 按平台下载并安装 ffmpeg/ffprobe 到 data/tools/ffmpeg/,
|
||||
// 返回两个可执行文件的绝对路径。progress 用于回传阶段消息(UI 展示)。
|
||||
func installFFmpegTools(ctx context.Context, log *zap.Logger, cfg *config.Config, progress func(string)) (ffmpegPath, ffprobePath string, err error) {
|
||||
target, err := ffmpegTargetForPlatform()
|
||||
if err != nil {
|
||||
return "./tools/ffmpeg"
|
||||
return "", "", err
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
return filepath.Join(exeDir, "tools", "ffmpeg")
|
||||
}
|
||||
|
||||
// tryAutoInstall 尝试自动下载并安装 ffmpeg
|
||||
func tryAutoInstall(log *zap.Logger, installDir string) (bool, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return downloadFFmpegWindows(log, installDir)
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// downloadFFmpegWindows 下载 Windows 版本的 ffmpeg
|
||||
func downloadFFmpegWindows(log *zap.Logger, installDir string) (bool, error) {
|
||||
log.Info("开始下载 ffmpeg...")
|
||||
|
||||
// 创建安装目录
|
||||
installDir := filepath.Join(cfg.App.DataDir, "tools", "ffmpeg")
|
||||
if err := os.MkdirAll(installDir, 0o750); err != nil {
|
||||
return false, fmt.Errorf("创建安装目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("创建安装目录失败: %w", err)
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "mmtl-ffmpeg-*")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建临时目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 下载 URL (使用 gyani.org 的静态构建)
|
||||
arch := "win64"
|
||||
if !is64Bit() {
|
||||
arch = "win32"
|
||||
progress("下载 " + target.Label + " 版本…")
|
||||
archivePath := filepath.Join(tempDir, "ffmpeg-archive."+target.Kind)
|
||||
if err := downloadFFmpegArchive(ctx, log, target.Archives, archivePath); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 先尝试从 gyan.dev 下载(更可靠)
|
||||
downloadURL := fmt.Sprintf("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
|
||||
progress("解压…")
|
||||
extractDir := filepath.Join(tempDir, "extract")
|
||||
if err := extractFFmpegArchive(target.Kind, archivePath, extractDir); err != nil {
|
||||
return "", "", fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
log.Info("下载 ffmpeg", zap.String("url", downloadURL))
|
||||
exeSuffix := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
exeSuffix = ".exe"
|
||||
}
|
||||
srcFFmpeg, srcFFprobe, err := locateFFmpegBinaries(extractDir, exeSuffix)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 使用 Go 下载
|
||||
zipPath := filepath.Join(tempDir, "ffmpeg.zip")
|
||||
if err := downloadFile(log, downloadURL, zipPath); err != nil {
|
||||
// 尝试备用 URL
|
||||
backupURL := fmt.Sprintf("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-%s-gpl.zip", arch)
|
||||
log.Info("尝试备用下载地址", zap.String("url", backupURL))
|
||||
if err2 := downloadFile(log, backupURL, zipPath); err2 != nil {
|
||||
return false, fmt.Errorf("下载失败: %v, %v", err, err2)
|
||||
progress("安装到 data 目录…")
|
||||
ffmpegPath = filepath.Join(installDir, "ffmpeg"+exeSuffix)
|
||||
ffprobePath = filepath.Join(installDir, "ffprobe"+exeSuffix)
|
||||
if err := copyFileMode(srcFFmpeg, ffmpegPath); err != nil {
|
||||
return "", "", fmt.Errorf("复制 ffmpeg 失败: %w", err)
|
||||
}
|
||||
if err := copyFileMode(srcFFprobe, ffprobePath); err != nil {
|
||||
_ = os.Remove(ffmpegPath)
|
||||
return "", "", fmt.Errorf("复制 ffprobe 失败: %w", err)
|
||||
}
|
||||
|
||||
// 验证两个工具都能运行(失败则回滚,避免留下坏文件)。
|
||||
for _, bin := range []string{ffmpegPath, ffprobePath} {
|
||||
cmd := exec.CommandContext(ctx, bin, "-version") // #nosec G204 -- bin 是安装目录中刚写入的固定文件名。
|
||||
if out, verr := cmd.Output(); verr != nil {
|
||||
_ = os.Remove(ffmpegPath)
|
||||
_ = os.Remove(ffprobePath)
|
||||
return "", "", fmt.Errorf("安装后 %s 无法运行:%v", filepath.Base(bin), verr)
|
||||
} else if log != nil {
|
||||
log.Info("ffmpeg 工具安装验证通过", zap.String("bin", filepath.Base(bin)),
|
||||
zap.String("version", strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])))
|
||||
}
|
||||
}
|
||||
|
||||
// 解压
|
||||
log.Info("解压 ffmpeg...")
|
||||
extractDir := filepath.Join(tempDir, "extract")
|
||||
if err := unzip(log, zipPath, extractDir); err != nil {
|
||||
return false, fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
|
||||
packageRoot, err := findFFmpegPackageRoot(extractDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := copyDirContents(packageRoot, installDir); err != nil {
|
||||
return false, fmt.Errorf("复制 ffmpeg 文件失败: %w", err)
|
||||
}
|
||||
|
||||
ffmpegBin := filepath.Join(installDir, "bin", "ffmpeg.exe")
|
||||
ffprobeBin := filepath.Join(installDir, "bin", "ffprobe.exe")
|
||||
if _, err := os.Stat(ffmpegBin); err != nil {
|
||||
return false, fmt.Errorf("安装后未找到 ffmpeg: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(ffprobeBin); err != nil {
|
||||
return false, fmt.Errorf("安装后未找到 ffprobe: %w", err)
|
||||
}
|
||||
|
||||
log.Info("ffmpeg 安装完成", zap.String("dir", installDir))
|
||||
return true, nil
|
||||
progress("安装完成")
|
||||
return ffmpegPath, ffprobePath, nil
|
||||
}
|
||||
|
||||
// downloadFile 下载文件
|
||||
func downloadFile(log *zap.Logger, url, filepath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
// downloadFFmpegArchive 按顺序尝试下载源,全部失败才返回错误。
|
||||
func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string, dest string) error {
|
||||
var lastErr error
|
||||
for i, u := range urls {
|
||||
if i > 0 && log != nil {
|
||||
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
|
||||
}
|
||||
if err := downloadFFmpegFile(ctx, log, u, dest); err != nil {
|
||||
lastErr = err
|
||||
if log != nil {
|
||||
log.Warn("ffmpeg 下载失败", zap.String("url", u), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("所有下载源均失败:%v", lastErr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
|
||||
func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "MMTL/ffmpeg-installer ("+runtime.GOOS+"/"+runtime.GOARCH+")")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载失败,HTTP 状态码: %d", resp.StatusCode)
|
||||
return fmt.Errorf("下载失败,HTTP 状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath) // #nosec G304 -- filepath is generated by the installer under its temporary download directory.
|
||||
out, err := os.Create(dest) // #nosec G304 -- dest 是安装器在临时目录下生成的文件。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
n, err := io.Copy(out, io.LimitReader(resp.Body, 500<<20+1)) // 归档上限 500MB
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 500<<20 {
|
||||
return fmt.Errorf("归档文件过大(>500MB): %s", url)
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateConfigPaths 更新配置文件中的路径
|
||||
func updateConfigPaths(cfg *config.Config, ffprobePath, ffmpegPath string) {
|
||||
cfg.App.FFprobePath = ffprobePath
|
||||
cfg.App.FFmpegPath = ffmpegPath
|
||||
|
||||
// 保存到配置文件
|
||||
// 这里需要调用 config 包的保存函数
|
||||
log := zap.L().Named("config")
|
||||
log.Info("已更新 ffmpeg 路径配置",
|
||||
zap.String("ffprobe", ffprobePath),
|
||||
zap.String("ffmpeg", ffmpegPath))
|
||||
// extractFFmpegArchive 按类型解压 zip 或 tar.xz。
|
||||
func extractFFmpegArchive(kind, archivePath, destDir string) error {
|
||||
switch kind {
|
||||
case "zip":
|
||||
return unzip(nil, archivePath, destDir)
|
||||
case "tar.xz":
|
||||
return untarXZ(archivePath, destDir)
|
||||
default:
|
||||
return fmt.Errorf("不支持的归档类型: %s", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// is64Bit 检查是否为 64 位系统
|
||||
func is64Bit() bool {
|
||||
return true // 简化处理,假设为 64 位
|
||||
// untarXZ 解压 .tar.xz 归档(GNU tar + xz 流式解压,纯 Go 无外部依赖),
|
||||
// 路径安全校验与 ZIP 解压一致。
|
||||
func untarXZ(archivePath, destDir string) error {
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
destRoot, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(archivePath) // #nosec G304 -- archivePath 是安装器在临时目录下生成的文件。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
xzReader, err := xz.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(xzReader)
|
||||
var totalWritten int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := safeZipTarget(destRoot, hdr.Name) // 与 ZIP 相同的路径穿越防护
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode).Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, copyErr := io.Copy(dst, io.LimitReader(tr, maxFFmpegZipEntryBytes+1))
|
||||
totalWritten += written
|
||||
closeErr := dst.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if written > maxFFmpegZipEntryBytes || totalWritten > maxFFmpegZipTotalBytes {
|
||||
return fmt.Errorf("tar 内容过大: %s", hdr.Name)
|
||||
}
|
||||
default:
|
||||
// 符号链接/设备等一律跳过(静态构建不会依赖它们)。
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// locateFFmpegBinaries 在解压目录中查找 ffmpeg/ffprobe 可执行文件(兼容
|
||||
// 不同构建包的目录布局:gyan 的 bin/、BtbN/johnvansickle 的根目录等)。
|
||||
func locateFFmpegBinaries(root, exeSuffix string) (ffmpeg, ffprobe string, err error) {
|
||||
wantFFmpeg := "ffmpeg" + strings.ToLower(exeSuffix)
|
||||
wantFFprobe := "ffprobe" + strings.ToLower(exeSuffix)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(d.Name()) {
|
||||
case wantFFmpeg:
|
||||
if ffmpeg == "" {
|
||||
ffmpeg = path
|
||||
}
|
||||
case wantFFprobe:
|
||||
if ffprobe == "" {
|
||||
ffprobe = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||
}
|
||||
if ffmpeg == "" || ffprobe == "" {
|
||||
return "", "", fmt.Errorf("解压内容中未找到 ffmpeg/ffprobe 可执行文件")
|
||||
}
|
||||
return ffmpeg, ffprobe, nil
|
||||
}
|
||||
|
||||
// copyFileMode 复制文件并赋予可执行权限。
|
||||
func copyFileMode(src, dst string) error {
|
||||
in, err := os.Open(src) // #nosec G304 -- src 来自解压目录遍历结果。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ func unzip(log *zap.Logger, zipPath, destDir string) error {
|
||||
}
|
||||
info := file.FileInfo()
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
|
||||
if log != nil {
|
||||
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
@@ -110,93 +112,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
|
||||
return targetAbs, nil
|
||||
}
|
||||
|
||||
func findFFmpegPackageRoot(root string) (string, error) {
|
||||
var ffmpegPath string
|
||||
var ffprobePath string
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(d.Name()) {
|
||||
case "ffmpeg.exe":
|
||||
ffmpegPath = path
|
||||
case "ffprobe.exe":
|
||||
ffprobePath = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||
}
|
||||
if ffmpegPath == "" || ffprobePath == "" {
|
||||
return "", fmt.Errorf("解压后未找到 ffmpeg/ffprobe 可执行文件")
|
||||
}
|
||||
|
||||
return filepath.Dir(filepath.Dir(ffmpegPath)), nil
|
||||
}
|
||||
|
||||
func copyDirContents(srcDir, dstDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
dstPath := filepath.Join(dstDir, entry.Name())
|
||||
if err := copyTree(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyTree(srcPath, dstPath string) error {
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := os.MkdirAll(dstPath, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyTree(filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
in, err := os.Open(srcPath) // #nosec G304 -- srcPath is produced by walking the validated extracted ffmpeg package tree.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// CheckFFmpegStatus 检查 ffmpeg/ffprobe 状态 (供 API 使用)
|
||||
func CheckFFmpegStatus(ffprobePath, ffmpegPath string) map[string]interface{} {
|
||||
status := map[string]interface{}{
|
||||
"ffprobe_installed": false,
|
||||
"ffmpeg_installed": false,
|
||||
"auto_installable": runtime.GOOS == "windows",
|
||||
}
|
||||
|
||||
if ffprobePath != "" {
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
status["ffprobe_installed"] = true
|
||||
status["ffprobe_path"] = ffprobePath
|
||||
|
||||
// 获取版本
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
// 提取版本信息(第一行)
|
||||
lines := bytes.Split(out, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
version := string(bytes.TrimSpace(lines[0]))
|
||||
status["ffprobe_version"] = version
|
||||
status["ffprobe_security"] = EvaluateFFmpegSecurity(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ffmpegPath != "" {
|
||||
if _, err := os.Stat(ffmpegPath); err == nil {
|
||||
status["ffmpeg_installed"] = true
|
||||
status["ffmpeg_path"] = ffmpegPath
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := bytes.Split(out, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
version := string(bytes.TrimSpace(lines[0]))
|
||||
status["ffmpeg_version"] = version
|
||||
status["ffmpeg_security"] = EvaluateFFmpegSecurity(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package service — ffmpeg/ffprobe 自动下载安装。
|
||||
//
|
||||
// FFmpegToolsService 负责「一键下载」:点击后按当前运行平台(OS+架构)选择
|
||||
// 官方构建包(Windows: gyan.dev / BtbN;Linux: BtbN / johnvansickle;
|
||||
// macOS: BtbN),下载解压 ffmpeg/ffprobe 到 data 目录(data/tools/ffmpeg/),
|
||||
// 并把绝对路径写入设置(ffmpeg.path / ffprobe.path),系统随即使用安装的
|
||||
// 工具,无需手动填写路径。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
// FFmpegToolsService 管理 ffmpeg/ffprobe 的自动下载安装状态(单飞:同一时间
|
||||
// 只允许一个安装任务)。
|
||||
type FFmpegToolsService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
msg string // 最近阶段/结果消息
|
||||
errMsg string // 最近一次失败原因
|
||||
started time.Time // 最近一次安装开始时间
|
||||
done time.Time // 最近一次安装结束时间
|
||||
}
|
||||
|
||||
// NewFFmpegToolsService 构造工具安装服务。
|
||||
func NewFFmpegToolsService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *FFmpegToolsService {
|
||||
return &FFmpegToolsService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// ffmpegInstallDir 返回 data 目录下的安装位置。
|
||||
func (s *FFmpegToolsService) ffmpegInstallDir() string {
|
||||
return filepath.Join(s.cfg.App.DataDir, "tools", "ffmpeg")
|
||||
}
|
||||
|
||||
// installedBinaries 检查安装目录中是否已存在 ffmpeg/ffprobe 可执行文件。
|
||||
func (s *FFmpegToolsService) installedBinaries() (ffmpeg, ffprobe string) {
|
||||
exe := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
exe = ".exe"
|
||||
}
|
||||
ffmpeg = filepath.Join(s.ffmpegInstallDir(), "ffmpeg"+exe)
|
||||
ffprobe = filepath.Join(s.ffmpegInstallDir(), "ffprobe"+exe)
|
||||
if _, err := os.Stat(ffmpeg); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if _, err := os.Stat(ffprobe); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return ffmpeg, ffprobe
|
||||
}
|
||||
|
||||
// ffToolVersion 取工具第一行版本信息。
|
||||
func ffToolVersion(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(path, "-version").Output() // #nosec G204 -- path 来自配置/安装目录中的已知工具。
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)
|
||||
if len(line) == 0 || strings.TrimSpace(line[0]) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(line[0])
|
||||
}
|
||||
|
||||
// ffToolInfo 是单个工具的安装状态(返回给前端展示)。
|
||||
type ffToolInfo struct {
|
||||
Installed bool `json:"installed"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// Status 返回当前安装状态(供 GET /api/admin/tools/ffmpeg/status 使用)。
|
||||
func (s *FFmpegToolsService) Status(ctx context.Context) map[string]any {
|
||||
s.mu.Lock()
|
||||
running, msg, errMsg, started, done := s.running, s.msg, s.errMsg, s.started, s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
startedAt, doneAt := "", ""
|
||||
if !started.IsZero() {
|
||||
startedAt = started.Format(time.RFC3339)
|
||||
}
|
||||
if !done.IsZero() {
|
||||
doneAt = done.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"installing": running,
|
||||
"message": msg,
|
||||
"error": errMsg,
|
||||
"started_at": startedAt,
|
||||
"finished_at": doneAt,
|
||||
"install_dir": s.ffmpegInstallDir(),
|
||||
}
|
||||
target, targetErr := ffmpegTargetForPlatform()
|
||||
if targetErr != nil {
|
||||
out["target"] = map[string]any{"label": targetErr.Error()}
|
||||
} else {
|
||||
out["target"] = map[string]any{
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"label": target.Label,
|
||||
}
|
||||
}
|
||||
// 报告「系统当前实际会使用」的工具:优先已生效配置(安装完成会把设置指到
|
||||
// data 目录),其次 PATH / 常见目录。
|
||||
ffmpegPath, ferr := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
ffprobePath, perr := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
out["ffmpeg"] = ffToolInfo{Installed: ferr == nil, Path: ffmpegPath, Version: ffToolVersion(ffmpegPath)}
|
||||
out["ffprobe"] = ffToolInfo{Installed: perr == nil, Path: ffprobePath, Version: ffToolVersion(ffprobePath)}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartInstall 启动后台安装(幂等)。正在安装时返回错误;data 目录已有完整
|
||||
// 工具时直接应用路径设置并返回(无需重新下载)。
|
||||
func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return errors.New("工具正在安装中,请稍候")
|
||||
}
|
||||
if ffmpeg, ffprobe := s.installedBinaries(); ffmpeg != "" && ffprobe != "" {
|
||||
s.mu.Unlock()
|
||||
s.setMessage("检测到已安装,直接应用配置")
|
||||
if err := s.applyInstalledPaths(ctx, ffmpeg, ffprobe); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
s.running = true
|
||||
s.errMsg = ""
|
||||
s.started = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
s.setMessage("准备下载…")
|
||||
go s.runInstall()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runInstall 在后台执行下载、解压、验证与配置落盘。
|
||||
func (s *FFmpegToolsService) runInstall() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.done = time.Now()
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ffmpegPath, ffprobePath, err := installFFmpegTools(ctx, s.log, s.cfg, s.setMessage)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
s.errMsg = err.Error()
|
||||
s.msg = "安装失败"
|
||||
s.mu.Unlock()
|
||||
s.log.Error("ffmpeg 工具安装失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.applyInstalledPaths(ctx, ffmpegPath, ffprobePath); err != nil {
|
||||
s.mu.Lock()
|
||||
s.errMsg = "安装完成,但写入设置失败:" + err.Error()
|
||||
s.msg = "安装完成,设置写入失败"
|
||||
s.mu.Unlock()
|
||||
s.log.Error("写入 ffmpeg 工具路径设置失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.setMessage("安装完成")
|
||||
s.log.Info("ffmpeg 工具安装完成",
|
||||
zap.String("ffmpeg", ffmpegPath), zap.String("ffprobe", ffprobePath))
|
||||
}
|
||||
|
||||
// applyInstalledPaths 把安装后的路径写入设置表并热应用到运行配置。
|
||||
func (s *FFmpegToolsService) applyInstalledPaths(ctx context.Context, ffmpeg, ffprobe string) error {
|
||||
if s.repo != nil && s.repo.Setting != nil {
|
||||
if err := s.repo.Setting.Set(ctx, "ffmpeg.path", ffmpeg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Setting.Set(ctx, "ffprobe.path", ffprobe); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ApplyRuntimeSetting(s.cfg, "ffmpeg.path", ffmpeg)
|
||||
ApplyRuntimeSetting(s.cfg, "ffprobe.path", ffprobe)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FFmpegToolsService) setMessage(msg string) {
|
||||
s.mu.Lock()
|
||||
s.msg = msg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
)
|
||||
|
||||
func TestFFmpegTargetForPlatform(t *testing.T) {
|
||||
target, err := ffmpegTargetForPlatform()
|
||||
switch runtime.GOOS {
|
||||
case "windows", "linux", "darwin":
|
||||
if err != nil {
|
||||
t.Fatalf("supported platform %s/%s should resolve a target: %v", runtime.GOOS, runtime.GOARCH, err)
|
||||
}
|
||||
if target == nil || target.Label == "" || len(target.Archives) == 0 {
|
||||
t.Fatalf("target incomplete: %#v", target)
|
||||
}
|
||||
if target.Kind != "zip" && target.Kind != "tar.xz" {
|
||||
t.Fatalf("unexpected archive kind: %s", target.Kind)
|
||||
}
|
||||
for _, u := range target.Archives {
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
t.Fatalf("archive url not https: %s", u)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if err == nil {
|
||||
t.Fatalf("unsupported platform %s/%s should fail", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeZipTargetRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"../evil", "..\\evil", "/etc/passwd", "a/../../evil"} {
|
||||
if _, err := safeZipTarget(root, name); err == nil {
|
||||
t.Fatalf("expected traversal rejection for %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := safeZipTarget(root, "bin/ffmpeg.exe"); err != nil {
|
||||
t.Fatalf("valid relative path should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
|
||||
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
|
||||
st := svc.Status(context.Background())
|
||||
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
|
||||
if _, ok := st[key]; !ok {
|
||||
t.Fatalf("status missing key %q: %#v", key, st)
|
||||
}
|
||||
}
|
||||
ffmpeg, ok := st["ffmpeg"].(ffToolInfo)
|
||||
if !ok {
|
||||
t.Fatalf("ffmpeg field not ffToolInfo: %T", st["ffmpeg"])
|
||||
}
|
||||
if ffmpeg.Installed {
|
||||
t.Fatalf("empty config should not report installed ffmpeg: %#v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartInstallRejectsConcurrent(t *testing.T) {
|
||||
svc := NewFFmpegToolsService(&config.Config{App: config.AppConfig{DataDir: t.TempDir()}}, zap.NewNop(), nil)
|
||||
// 不真实运行:直接占用 running 标记模拟进行中的安装。
|
||||
svc.mu.Lock()
|
||||
svc.running = true
|
||||
svc.mu.Unlock()
|
||||
if err := svc.StartInstall(context.Background()); err == nil {
|
||||
t.Fatalf("second install while running should be rejected")
|
||||
}
|
||||
svc.mu.Lock()
|
||||
svc.running = false
|
||||
svc.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -75,6 +77,74 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, visibility MediaVisibility) ([]model.Media, error) {
|
||||
target, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
if !visibility.Allows(target) {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
if target.LibraryID == "" {
|
||||
return []model.Media{*target}, nil
|
||||
}
|
||||
rows, _, err := s.listAllMediaVisible(ctx, target.LibraryID, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []model.Media{*target}, nil
|
||||
}
|
||||
|
||||
resolver := newMediaSeriesKeyResolver(rows)
|
||||
targetKey := resolver.key(*target)
|
||||
|
||||
out := make([]model.Media, 0)
|
||||
if targetKey != "" {
|
||||
for _, row := range rows {
|
||||
if resolver.key(row) == targetKey {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SeasonNum != out[j].SeasonNum {
|
||||
return out[i].SeasonNum < out[j].SeasonNum
|
||||
}
|
||||
if out[i].EpisodeNum != out[j].EpisodeNum {
|
||||
return out[i].EpisodeNum < out[j].EpisodeNum
|
||||
}
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||
const pageSize = 2000
|
||||
var all []model.Media
|
||||
|
||||
@@ -62,6 +62,7 @@ type Container struct {
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
Database *DatabaseAdminService
|
||||
FFTools *FFmpegToolsService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
@@ -111,6 +111,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||
// ffmpeg/ffprobe 一键下载安装(data/tools/ffmpeg/)。
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,64 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
||||
return nil, errors.New("文件不在任何本地同步目录内")
|
||||
}
|
||||
|
||||
// ResolvePlayTarget 解析媒体行固化的播放目标(STRMURL 或 .strm 文件内容)为
|
||||
// 可播放结果,供弹幕 hash、内嵌字幕提取等「先解析直链再读取远端」的场景复用。
|
||||
// 支持:
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||
// - 绝对 http(s) 链接(直接透传)
|
||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errors.New("空播放目标")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||
}
|
||||
lowerPath := strings.ToLower(u.Path)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||
segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/")
|
||||
if len(segs) < 1 || strings.TrimSpace(segs[0]) == "" {
|
||||
return nil, errors.New("无效的 strm 播放地址")
|
||||
}
|
||||
return s.ResolvePlay(ctx, segs[0], u.Query())
|
||||
case strings.HasPrefix(lowerPath, "/api/cloud/play/"):
|
||||
typ := strings.TrimSpace(strings.TrimPrefix(u.Path, "/api/cloud/play/"))
|
||||
acct, err := s.firstEnabledAccountOf(ctx, typ)
|
||||
if err != nil || acct == nil {
|
||||
return nil, errors.New("没有可用的网盘账号,无法解析直链")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("acct", acct.ID)
|
||||
return s.ResolvePlay(ctx, typ, q)
|
||||
case u.Scheme == "http" || u.Scheme == "https":
|
||||
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range accounts {
|
||||
a := &accounts[i]
|
||||
if !a.Enabled || a.Provider != provider {
|
||||
continue
|
||||
}
|
||||
if _, err := s.providerFor(ctx, a); err == nil {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||
if link == nil || link.URL == "" {
|
||||
|
||||
+16
-179
@@ -1,8 +1,8 @@
|
||||
// Package service — subtitle handling.
|
||||
//
|
||||
// SubtitleService finds external subtitle files next to a media file AND
|
||||
// embedded text subtitle tracks inside the media container, exposing both as
|
||||
// WebVTT so the browser <track> element can load them directly.
|
||||
// SubtitleService finds external subtitle files next to a media file and
|
||||
// exposes them as WebVTT so the browser <track> element can load them
|
||||
// directly, or as the original bytes for Emby/Jellyfin clients.
|
||||
//
|
||||
// External-subtitle discovery rules (matching the legacy Python defaults):
|
||||
//
|
||||
@@ -12,25 +12,17 @@
|
||||
// ?lang=zh / ?lang=en.
|
||||
//
|
||||
// Supported extensions: .srt, .ass, .ssa, .vtt.
|
||||
//
|
||||
// Embedded subtitles are probed with ffprobe and exposed as
|
||||
// path "embedded:<stream-index>"; the browser endpoint extracts the stream
|
||||
// via ffmpeg into a cached .vtt file.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -71,6 +63,16 @@ var extToCodec = map[string]string{
|
||||
// relative; the caller should prepend /api/subtitles/<media_id>?path=...
|
||||
// when serializing for the frontend.
|
||||
func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
}
|
||||
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,94 +120,9 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 容器内嵌文本字幕轨(MKV/MP4 等封装内的字幕流):本地真实文件才可
|
||||
// 探测提取;cloud:// 与 .strm 媒体跳过。探测失败静默忽略(无 ffprobe
|
||||
// 或没有字幕流都属正常)。
|
||||
if embedded, ok := s.discoverEmbeddedTracks(ctx, m.Path); ok {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// embeddedCodecOK 只暴露可提取为 WebVTT 的文本字幕编解码器;位图字幕
|
||||
// (PGS/DVDSUB/DVBSUB)浏览器无法渲染,跳过。
|
||||
func embeddedCodecOK(codec string) bool {
|
||||
switch strings.ToLower(codec) {
|
||||
case "subrip", "srt", "mov_text", "text", "webvtt", "ass", "ssa", "ttml", "sami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ffprobeSubtitleStream 是 ffprobe -show_streams 输出的字幕流字段。
|
||||
type ffprobeSubtitleStream struct {
|
||||
Index int `json:"index"`
|
||||
Codec string `json:"codec_name"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type ffprobeSubtitleContainer struct {
|
||||
Streams []ffprobeSubtitleStream `json:"streams"`
|
||||
}
|
||||
|
||||
// discoverEmbeddedTracks 用 ffprobe 探测媒体容器内的文本字幕轨。
|
||||
// 返回 (tracks, ok):ok=false 表示该媒体不适用(非本地文件/ffprobe 不可用)。
|
||||
func (s *SubtitleService) discoverEmbeddedTracks(ctx context.Context, mediaPath string) ([]SubtitleTrack, bool) {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaPath)), "cloud://") ||
|
||||
strings.HasSuffix(strings.ToLower(strings.TrimSpace(mediaPath)), ".strm") {
|
||||
return nil, false
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := os.Stat(mediaPath); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args are fixed probes.
|
||||
"-v", "error",
|
||||
"-select_streams", "s",
|
||||
"-show_entries", "stream=index,codec_name:stream_tags=language,title",
|
||||
"-of", "json",
|
||||
"--", mediaPath,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var container ffprobeSubtitleContainer
|
||||
if err := json.Unmarshal(out, &container); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tracks := make([]SubtitleTrack, 0, len(container.Streams))
|
||||
for _, stream := range container.Streams {
|
||||
if !embeddedCodecOK(stream.Codec) {
|
||||
continue
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(stream.Tags["language"]))
|
||||
if lang == "" {
|
||||
lang = "und"
|
||||
}
|
||||
label := stream.Tags["title"]
|
||||
if label == "" {
|
||||
label = lang
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: "内置字幕 · " + label,
|
||||
Path: "embedded:" + strconv.Itoa(stream.Index),
|
||||
Codec: stream.Codec,
|
||||
})
|
||||
}
|
||||
return tracks, true
|
||||
}
|
||||
|
||||
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
||||
|
||||
@@ -222,18 +139,13 @@ func detectLang(name, base string) string {
|
||||
}
|
||||
|
||||
// Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are
|
||||
// converted minimally on the fly; embedded container tracks (path
|
||||
// "embedded:<index>") are extracted via ffmpeg into a cached .vtt.
|
||||
// Returns ErrSubtitleNotFound when the path is rejected (path traversal /
|
||||
// not in the media directory).
|
||||
// converted minimally on the fly. Returns ErrSubtitleNotFound when the
|
||||
// path is rejected (path traversal / not in the media directory).
|
||||
func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.Writer) error {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
return s.ServeEmbeddedToVTT(ctx, m.Path, sub, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -266,81 +178,6 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
return err
|
||||
}
|
||||
|
||||
// embeddedSubtitleCachePath 内嵌字幕提取后的 WebVTT 缓存路径
|
||||
// (按媒体路径哈希 + 轨道号定位,跨媒体互不干扰)。
|
||||
func (s *SubtitleService) embeddedSubtitleCachePath(mediaPath string, idx int) string {
|
||||
hash := fmt.Sprintf("%x", fnvHash(mediaPath))
|
||||
return filepath.Join(s.cfg.Cache.CacheDir, "subs", hash, fmt.Sprintf("s%d.vtt", idx))
|
||||
}
|
||||
|
||||
// ServeEmbeddedToVTT 把容器内第 idx 个字幕轨提取为 WebVTT 输出。
|
||||
// 提取结果缓存在 cache 目录,媒体文件更新(mtime 变化)后自动重新提取。
|
||||
func (s *SubtitleService) ServeEmbeddedToVTT(ctx context.Context, mediaPath, streamRef string, w io.Writer) error {
|
||||
idx, err := strconv.Atoi(strings.TrimPrefix(streamRef, "embedded:"))
|
||||
if err != nil || idx < 0 {
|
||||
return errors.New("invalid embedded subtitle index")
|
||||
}
|
||||
ffmpegBin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg 不可用,无法提取内嵌字幕:%w", err)
|
||||
}
|
||||
info, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
return errors.New("media file not found")
|
||||
}
|
||||
|
||||
cachePath := s.embeddedSubtitleCachePath(mediaPath, idx)
|
||||
|
||||
if cached, statErr := os.Stat(cachePath); statErr == nil && !info.ModTime().After(cached.ModTime()) {
|
||||
f, openErr := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
|
||||
if openErr == nil {
|
||||
defer f.Close()
|
||||
_, copyErr := io.Copy(w, f)
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中或媒体已更新:ffmpeg 提取到临时文件后原子改名。
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := cachePath + ".tmp"
|
||||
extractCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(extractCtx, ffmpegBin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args fixed extraction.
|
||||
"-v", "error", "-y",
|
||||
"-i", mediaPath,
|
||||
"-map", "0:s:"+strconv.Itoa(idx),
|
||||
"-f", "webvtt",
|
||||
tmp,
|
||||
)
|
||||
if out, runErr := cmd.CombinedOutput(); runErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("提取内嵌字幕失败(轨道 %d,可能为位图字幕或轨道无效):%s", idx, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err := os.Rename(tmp, cachePath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// fnvHash 简单 32 位 FNV-1a 哈希,用于生成稳定的缓存子目录名。
|
||||
func fnvHash(s string) uint32 {
|
||||
var h uint32 = 2166136261
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint32(s[i])
|
||||
h *= 16777619
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeRaw writes the subtitle file in its original format without any
|
||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||
@@ -368,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user