diff --git a/go.mod b/go.mod index 43917a0..a678106 100644 --- a/go.mod +++ b/go.mod @@ -68,6 +68,7 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/ulikunitz/xz v0.5.12 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect diff --git a/go.sum b/go.sum index 00ae618..d574336 100644 --- a/go.sum +++ b/go.sum @@ -152,6 +152,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index e2d90b6..07d8278 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -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) { diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 3434a30..666add4 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -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)) diff --git a/internal/handler/series.go b/internal/handler/series.go index 108c386..7b7ada2 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -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)}) + } +} diff --git a/internal/handler/subtitles.go b/internal/handler/subtitles.go index 3c7fd40..fff5b33 100644 --- a/internal/handler/subtitles.go +++ b/internal/handler/subtitles.go @@ -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 diff --git a/internal/handler/system_handler.go b/internal/handler/system_handler.go deleted file mode 100644 index 0d34b29..0000000 --- a/internal/handler/system_handler.go +++ /dev/null @@ -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) -} diff --git a/internal/handler/tools_ffmpeg.go b/internal/handler/tools_ffmpeg.go new file mode 100644 index 0000000..7912c01 --- /dev/null +++ b/internal/handler/tools_ffmpeg.go @@ -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())) + } +} diff --git a/internal/service/emby_media_sources.go b/internal/service/emby_media_sources.go index 6a8edfc..2f4cdca 100644 --- a/internal/service/emby_media_sources.go +++ b/internal/service/emby_media_sources.go @@ -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 } diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index 652beed..6999b41 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -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 "" } diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go index 405bdd9..1d0ca6c 100644 --- a/internal/service/ffmpeg_auto_install.go +++ b/internal/service/ffmpeg_auto_install.go @@ -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() } diff --git a/internal/service/ffmpeg_install_archive.go b/internal/service/ffmpeg_install_archive.go index c8b7453..1bf43fa 100644 --- a/internal/service/ffmpeg_install_archive.go +++ b/internal/service/ffmpeg_install_archive.go @@ -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() -} diff --git a/internal/service/ffmpeg_status.go b/internal/service/ffmpeg_status.go deleted file mode 100644 index 730f9fc..0000000 --- a/internal/service/ffmpeg_status.go +++ /dev/null @@ -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 -} diff --git a/internal/service/ffmpeg_tools.go b/internal/service/ffmpeg_tools.go new file mode 100644 index 0000000..d8e7106 --- /dev/null +++ b/internal/service/ffmpeg_tools.go @@ -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() +} diff --git a/internal/service/ffmpeg_tools_test.go b/internal/service/ffmpeg_tools_test.go new file mode 100644 index 0000000..5e6488a --- /dev/null +++ b/internal/service/ffmpeg_tools_test.go @@ -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() +} + diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 704fdc2..2065b07 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -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 diff --git a/internal/service/service.go b/internal/service/service.go index f2f1c93..f801f04 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -62,6 +62,7 @@ type Container struct { Danmaku *DanmakuService Strm *StrmService Database *DatabaseAdminService + FFTools *FFmpegToolsService stopCtx context.Context stopCancel context.CancelFunc diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index bd6a0e6..a571914 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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) } diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go index 5aa3067..445ffa7 100644 --- a/internal/service/strm_play.go +++ b/internal/service/strm_play.go @@ -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 == "" { diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 6c06e7f..fa006b3 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -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 element can load them directly. +// SubtitleService finds external subtitle files next to a media file and +// exposes them as WebVTT so the browser 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:"; 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/?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:") 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 -} +} \ No newline at end of file diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 18a6056..c833715 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -197,6 +197,9 @@ export const mediaAPI = { get: (id: string) => api.get(`/media/${id}`).then((r) => r.data), + getEpisodes: (id: string) => + api.get<{ items: Media[]; total: number }>(`/media/${id}/episodes`).then((r) => r.data), + updateMetadata: (id: string, payload: MediaMetadataUpdate) => api.patch(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data), diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index a2a3c37..e9c8c6a 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -67,6 +67,24 @@ export interface OrganizeResultSummary { }> } +export interface FFmpegToolInfo { + installed: boolean + path?: string + version?: string +} + +export interface FFmpegToolsStatus { + installing?: boolean + message?: string + error?: string + started_at?: string + finished_at?: string + install_dir?: string + target?: { os?: string; arch?: string; label?: string } + ffmpeg?: FFmpegToolInfo + ffprobe?: FFmpegToolInfo +} + export const toolsAPI = { organizeMedia: (mediaID: string, opts?: OrganizeOverrides) => api @@ -102,6 +120,14 @@ export const toolsAPI = { .post<{ message: string }>('/admin/notify/test', { title, body }) .then((r) => r.data), + // ffToolsStatus 查询 ffmpeg/ffprobe 的安装状态(路径/版本/是否正在安装)。 + ffToolsStatus: () => + api.get('/admin/tools/ffmpeg/status').then((r) => r.data), + + // installFFTools 触发后台下载安装 ffmpeg/ffprobe(自动匹配当前平台)。 + installFFTools: () => + api.post('/admin/tools/ffmpeg/install').then((r) => r.data), + // repairAndRescrapeAll 触发「全库修复+重刮」:先从媒体路径中的 // {tmdb-N}/{bangumi-N} 占位符回填缺失/错误的外部 ID,再批量重刮整库。 // 后端异步执行,立即返回;进度通过 WS "scrape" topic 推送。 diff --git a/web/src/components/FFToolsPanel.tsx b/web/src/components/FFToolsPanel.tsx new file mode 100644 index 0000000..c5238f5 --- /dev/null +++ b/web/src/components/FFToolsPanel.tsx @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react' + +import { toolsAPI, type FFmpegToolInfo, type FFmpegToolsStatus } from '../api/tools' + +// FFToolsPanel 展示 ffmpeg/ffprobe 的安装状态,并提供「一键下载安装」: +// 后端自动匹配当前运行环境(OS + 架构),下载到 data 目录并写入设置, +// 无需手动填写路径。 +export function FFToolsPanel({ onInstalled }: { onInstalled?: () => void }) { + const [status, setStatus] = useState(null) + const [working, setWorking] = useState(false) + const pollingRef = useRef(false) + + const load = useCallback(async () => { + try { + setStatus(await toolsAPI.ffToolsStatus()) + } catch { + // 网络波动时保持旧状态,不打断页面 + } + }, []) + + useEffect(() => { + load().catch(() => undefined) + }, [load]) + + const installing = Boolean(status?.installing) + + // 安装进行中:轮询状态直到结束,完成后刷新设置页(路径字段自动填入)。 + useEffect(() => { + if (!installing || pollingRef.current) return + pollingRef.current = true + const timer = window.setInterval(async () => { + try { + const next = await toolsAPI.ffToolsStatus() + setStatus(next) + if (!next.installing) { + window.clearInterval(timer) + pollingRef.current = false + if (next.error) { + toast.error(`FFmpeg 下载安装失败:${next.error}`) + } else { + toast.success('FFmpeg / FFprobe 下载安装完成,系统已自动使用') + onInstalled?.() + } + } + } catch { + // 轮询失败继续等下一轮 + } + }, 1500) + return () => { + window.clearInterval(timer) + pollingRef.current = false + } + }, [installing, onInstalled]) + + const install = async () => { + setWorking(true) + try { + const next = await toolsAPI.installFFTools() + setStatus(next) + if (next.error && !next.installing) { + toast.error(next.error) + } else if (next.installing) { + toast('开始下载,完成后自动生效') + } else { + toast.success('已检测到可用工具') + onInstalled?.() + } + } catch (err) { + toast.error( + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + (err as { message?: string })?.message ?? + '启动下载失败', + ) + } finally { + setWorking(false) + } + } + + const busy = working || installing + const targetLabel = status?.target?.label ?? '当前平台' + const ffmpegInstalled = Boolean(status?.ffmpeg?.installed) + const ffprobeInstalled = Boolean(status?.ffprobe?.installed) + + return ( +
+
+
+
FFmpeg / FFprobe 工具
+
+ 当前平台:{targetLabel} · 安装目录: + {status?.install_dir ?? '…'} +
+
+ +
+ +
+ + +
+ + {(status?.message || status?.error) && ( +
+ {status?.error || status?.message} +
+ )} + + {!ffmpegInstalled || !ffprobeInstalled ? ( +
+ 未检测到可用工具。点击「下载并安装」后,服务端会自动匹配当前系统下载对应版本,安装完成后无需手动填写路径。 +
+ ) : null} +
+ ) +} + +function ToolRow({ name, info }: { name: string; info?: FFmpegToolInfo }) { + return ( +
+ {info?.installed ? ( + + ) : ( + + )} +
+
+ {name} {info?.installed ? '' : '(未安装)'} +
+ {info?.installed && ( +
+ {info.path} +
+ )} + {info?.version &&
{info.version}
} +
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index 32297a6..38c59d4 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -2,12 +2,15 @@ import { useEffect, useRef, useState } from 'react' import { Captions, CaptionsOff, + ListVideo, Maximize, MessageSquareText, Minimize, Pause, PictureInPicture, Play, + SkipBack, + SkipForward, Volume2, VolumeX, } from 'lucide-react' @@ -34,6 +37,15 @@ type PlayerControlsProps = { danmakuOpen: boolean danmakuEnabled: boolean onToggleDanmaku: () => void + hasPrevEpisode?: boolean + hasNextEpisode?: boolean + onPrevEpisode?: () => void + onNextEpisode?: () => void + prevEpisodeTitle?: string + nextEpisodeTitle?: string + playlistOpen?: boolean + hasPlaylist?: boolean + onTogglePlaylist?: () => void } export function PlayerControls({ @@ -44,6 +56,15 @@ export function PlayerControls({ danmakuOpen, danmakuEnabled, onToggleDanmaku, + hasPrevEpisode = false, + hasNextEpisode = false, + onPrevEpisode, + onNextEpisode, + prevEpisodeTitle, + nextEpisodeTitle, + playlistOpen = false, + hasPlaylist = false, + onTogglePlaylist, }: PlayerControlsProps) { const video = () => videoRef.current const container = () => @@ -69,6 +90,7 @@ export function PlayerControls({ const isScrubbingRef = useRef(false) const subtitleMenuOpenRef = useRef(false) const danmakuOpenRef = useRef(false) + const playlistOpenRef = useRef(false) useEffect(() => { controlsHoveredRef.current = controlsHovered @@ -86,6 +108,10 @@ export function PlayerControls({ danmakuOpenRef.current = danmakuOpen }, [danmakuOpen]) + useEffect(() => { + playlistOpenRef.current = playlistOpen + }, [playlistOpen]) + // 点击控制栏外部时关闭字幕菜单 useEffect(() => { if (!subtitleMenuOpen) return @@ -112,14 +138,16 @@ export function PlayerControls({ !controlsHoveredRef.current && !isScrubbingRef.current && !subtitleMenuOpenRef.current && - !danmakuOpenRef.current + !danmakuOpenRef.current && + !playlistOpenRef.current ) { hideTimerRef.current = setTimeout(() => { if ( !controlsHoveredRef.current && !isScrubbingRef.current && !subtitleMenuOpenRef.current && - !danmakuOpenRef.current + !danmakuOpenRef.current && + !playlistOpenRef.current ) { setUiVisible(false) } @@ -137,7 +165,7 @@ export function PlayerControls({ if (e.relatedTarget && stage.contains(e.relatedTarget as Node)) { return } - if (el.paused || controlsHoveredRef.current || isScrubbingRef.current) return + if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return if (hideTimerRef.current) clearTimeout(hideTimerRef.current) setUiVisible(false) } @@ -201,7 +229,7 @@ export function PlayerControls({ // 当悬停或菜单状态改变时,更新控制栏计时器 useEffect(() => { - if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen) { + if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) { setUiVisible(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) } else { @@ -211,7 +239,7 @@ export function PlayerControls({ hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000) } } - }, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen]) + }, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen]) const togglePlay = () => { const el = video() @@ -297,14 +325,43 @@ export function PlayerControls({ onClick={(e) => e.stopPropagation()} >
+ {/* 上一集 */} + + + {/* 播放 / 暂停 */} + {/* 下一集 */} + + )} + {/* 选集 / 播放列表按钮 */} + {onTogglePlaylist && ( + + )} + + {/* 弹幕按钮 */} @@ -419,7 +497,7 @@ export function PlayerControls({ diff --git a/web/src/components/PlayerPlaylistPanel.tsx b/web/src/components/PlayerPlaylistPanel.tsx new file mode 100644 index 0000000..c5fa915 --- /dev/null +++ b/web/src/components/PlayerPlaylistPanel.tsx @@ -0,0 +1,296 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react' + +import { imageURL } from '../api/client' +import type { Media } from '../types' +import { seriesTitleFromPath } from '../utils/groupSeries' + +export type SeasonGroup = { + season: number + episodes: Media[] +} + +type PlayerPlaylistPanelProps = { + open: boolean + onClose: () => void + currentMediaId: string + episodes: Media[] + onSelectEpisode: (media: Media) => void +} + +export function PlayerPlaylistPanel({ + open, + onClose, + currentMediaId, + episodes, + onSelectEpisode, +}: PlayerPlaylistPanelProps) { + const [filterText, setFilterText] = useState('') + const activeItemRef = useRef(null) + const listContainerRef = useRef(null) + + // 按季分组 + const seasonGroups = useMemo(() => { + if (!episodes || episodes.length === 0) return [] + const seasonsMap = new Map() + for (const ep of episodes) { + const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1) + if (!seasonsMap.has(s)) seasonsMap.set(s, []) + seasonsMap.get(s)!.push(ep) + } + for (const [, list] of seasonsMap) { + list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0)) + } + return Array.from(seasonsMap.entries()) + .sort(([a], [b]) => a - b) + .map(([season, list]) => ({ season, episodes: list })) + }, [episodes]) + + // 当前播放所在季 + const currentSeason = useMemo(() => { + const found = episodes.find((e) => e.id === currentMediaId) + if (!found) return seasonGroups[0]?.season ?? 1 + return found.episode_num > 0 ? (found.season_num ?? 0) : (found.season_num || 1) + }, [episodes, currentMediaId, seasonGroups]) + + const [selectedSeason, setSelectedSeason] = useState(currentSeason) + + // 当当前播放媒体改变或打开面板时,默认选中当前媒体所在的季 + useEffect(() => { + if (open) { + setSelectedSeason(currentSeason) + } + }, [open, currentSeason]) + + // 当面板打开时,自动平滑滚动到当前播放集的位置 + useEffect(() => { + if (open && activeItemRef.current) { + const timer = setTimeout(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }, 100) + return () => clearTimeout(timer) + } + }, [open, selectedSeason, currentMediaId]) + + if (!open) return null + + const currentGroup = seasonGroups.find((g) => g.season === selectedSeason) ?? seasonGroups[0] + const listToDisplay = currentGroup ? currentGroup.episodes : episodes + + const filteredEpisodes = filterText.trim() + ? listToDisplay.filter((ep) => { + const query = filterText.trim().toLowerCase() + const title = (ep.episode_title || ep.title || '').toLowerCase() + const epNum = String(ep.episode_num) + return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query) + }) + : listToDisplay + + return ( +
e.stopPropagation()} + className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden" + > + {/* 头部 */} +
+
+ + 选集列表 + + ({episodes.length} 集) + +
+ +
+ + {/* 季选择 Tabs(若有多季) */} + {seasonGroups.length > 1 && ( +
+ {seasonGroups.map(({ season, episodes: sesEps }) => { + const isSelected = selectedSeason === season + const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId) + return ( + + ) + })} +
+ )} + + {/* 搜索/过滤单集(当单集数量较多时) */} + {listToDisplay.length > 10 && ( +
+
+ + setFilterText(e.target.value)} + placeholder="搜索集数或标题…" + className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs" + /> + {filterText && ( + + )} +
+
+ )} + + {/* 集数列表 */} +
+ {filteredEpisodes.length === 0 ? ( +
+ {filterText ? '未找到匹配的剧集' : '暂无剧集列表'} +
+ ) : ( + filteredEpisodes.map((ep) => { + const isPlaying = ep.id === currentMediaId + const displayTitle = getEpisodeTitle(ep, listToDisplay) + const durationText = + ep.duration_sec > 0 ? `${Math.floor(ep.duration_sec / 60)} 分钟` : '' + + return ( +
onSelectEpisode(ep)} + className={`group flex cursor-pointer items-center gap-2.5 rounded-xl p-2 transition border ${ + isPlaying + ? 'border-rose-500/60 bg-rose-500/20 text-white' + : 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/10 text-white/85' + }`} + > + {/* 封面/集号 */} +
+ {ep.backdrop_url || ep.poster_url ? ( + + ) : ( + + )} + + {/* 正在播放动效 / 集数徽标 */} + {isPlaying ? ( +
+
+ + + +
+
+ ) : ( +
+ +
+ )} + + {/* 角标显示集数 */} + + {ep.episode_num > 0 ? `${ep.episode_num}` : '—'} + +
+ + {/* 标题 & 时长 */} +
+
+

+ {displayTitle} +

+
+
+ {ep.episode_num > 0 && ( + 第 {ep.episode_num} 集 + )} + {durationText && {durationText}} +
+
+ + {isPlaying && ( +
+ +
+ )} +
+ ) + }) + )} +
+
+ ) +} + +function getEpisodeTitle(ep: Media, siblings: Media[]): string { + const title = ep.episode_title?.trim() + if (title && !looksLikeSeriesTitle(ep, title, siblings)) { + return title + } + + const mediaTitle = ep.title?.trim() + if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) { + return mediaTitle + } + + return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名' +} + +function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean { + const normalized = normalizeEpisodeTitle(title) + if (!normalized) return true + if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true + const pathTitle = seriesTitleFromPath(ep.path) + if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true + + const siblingTitles = new Set( + siblings + .map((item) => normalizeEpisodeTitle(item.title)) + .filter(Boolean), + ) + return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1 +} + +function normalizeEpisodeTitle(value?: string): string { + return (value ?? '') + .toLowerCase() + .replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ') + .replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ') + .replace(/[\s._-]+/g, ' ') + .trim() +} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 566c572..fef6271 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -1,20 +1,21 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import type Hls from 'hls.js' import toast from 'react-hot-toast' -import { mediaAPI } from '../api/library' +import { mediaAPI, libraryAPI } from '../api/library' import { api, hlsURL, streamURL } from '../api/client' import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku' import { playbackAPI } from '../api/playback' import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' import { systemAPI } from '../api/system' import type { Media } from '../types' -import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries' +import { getSeriesKey, isEpisodeLike, seriesTitleFromPath } from '../utils/groupSeries' import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel' import { PlayerTopBar } from './PlayerTopBar' import { PlayerVideoStage } from './PlayerVideoStage' import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel' +import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel' // Fullscreen, dark-themed video page. // @@ -80,6 +81,10 @@ export function PlayerPage() { const [danmakuFontSize, setDanmakuFontSize] = useState(24) const [danmakuArea, setDanmakuArea] = useState(1) + // 选集 / 播放列表状态 + const [playlistEpisodes, setPlaylistEpisodes] = useState([]) + const [playlistOpen, setPlaylistOpen] = useState(false) + const teardownHls = useCallback((mediaId?: string, stopServer = false) => { if (hlsRef.current) { hlsRef.current.destroy() @@ -272,14 +277,160 @@ export function PlayerPage() { } }, [media]) - // ESC = back. + // 加载剧集/播放列表 + useEffect(() => { + if (!id) return + let canceled = false + mediaAPI + .getEpisodes(id) + .then((res) => { + if (canceled) return + setPlaylistEpisodes(res.items ?? []) + }) + .catch(() => { + if (canceled) return + if (media && (media.display_library_id || media.library_id)) { + const libId = media.display_library_id || media.library_id + const seriesKey = getSeriesKey(media) + if (seriesKey) { + libraryAPI + .listSeriesEpisodes(libId, seriesKey) + .then((res) => { + if (!canceled) setPlaylistEpisodes(res.items ?? []) + }) + .catch(() => { + if (!canceled) setPlaylistEpisodes([]) + }) + return + } + } + setPlaylistEpisodes([]) + }) + return () => { + canceled = true + } + }, [id, media]) + + const currentEpisodeIndex = useMemo(() => { + if (!media || playlistEpisodes.length === 0) return -1 + return playlistEpisodes.findIndex((e) => e.id === media.id) + }, [media, playlistEpisodes]) + + const prevEpisode = useMemo(() => { + if (currentEpisodeIndex > 0) { + return playlistEpisodes[currentEpisodeIndex - 1] + } + return null + }, [currentEpisodeIndex, playlistEpisodes]) + + const nextEpisode = useMemo(() => { + if (currentEpisodeIndex >= 0 && currentEpisodeIndex < playlistEpisodes.length - 1) { + return playlistEpisodes[currentEpisodeIndex + 1] + } + return null + }, [currentEpisodeIndex, playlistEpisodes]) + + const prevEpisodeTitle = useMemo(() => { + return prevEpisode ? formatEpisodeDisplay(prevEpisode, playlistEpisodes) : '' + }, [prevEpisode, playlistEpisodes]) + + const nextEpisodeTitle = useMemo(() => { + return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : '' + }, [nextEpisode, playlistEpisodes]) + + const playEpisode = useCallback( + (target: Media) => { + navigate( + { + pathname: `/play/${target.id}`, + search: location.search, + }, + { state: location.state }, + ) + }, + [navigate, location.search, location.state], + ) + + const handlePrevEpisode = useCallback(() => { + if (prevEpisode) { + playEpisode(prevEpisode) + } + }, [prevEpisode, playEpisode]) + + const handleNextEpisode = useCallback(() => { + if (nextEpisode) { + playEpisode(nextEpisode) + } + }, [nextEpisode, playEpisode]) + + const togglePlaylistOpen = useCallback(() => { + setPlaylistOpen((prev) => { + const next = !prev + if (next) setDanmakuOpen(false) + return next + }) + }, []) + + const toggleDanmakuOpen = useCallback(() => { + setDanmakuOpen((prev) => { + const next = !prev + if (next) setPlaylistOpen(false) + return next + }) + }, []) + + // 视频播放结束时自动播放下一集 + useEffect(() => { + if (!ref.current || !nextEpisode) return + const video = ref.current + const onEnded = () => { + toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`) + playEpisode(nextEpisode) + } + video.addEventListener('ended', onEnded) + return () => { + video.removeEventListener('ended', onEnded) + } + }, [nextEpisode, nextEpisodeTitle, playEpisode]) + + // ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集 useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') goBack() + const target = e.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return + } + + if (e.key === 'Escape') { + if (playlistOpen) { + setPlaylistOpen(false) + return + } + if (danmakuOpen) { + setDanmakuOpen(false) + return + } + goBack() + } else if (e.key === '[' || (e.shiftKey && e.key.toLowerCase() === 'p')) { + if (prevEpisode) { + e.preventDefault() + handlePrevEpisode() + } + } else if (e.key === ']' || (e.shiftKey && e.key.toLowerCase() === 'n')) { + if (nextEpisode) { + e.preventDefault() + handleNextEpisode() + } + } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [goBack]) + }, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen]) const toggleMode = useCallback(() => { const next = mode === 'hls' ? 'direct' : 'hls' @@ -344,9 +495,27 @@ export function PlayerPage() { danmakuSearch={danmakuSearch} danmakuEpisodeId={danmakuEpisodeId} danmakuOpen={danmakuOpen} - onToggleDanmaku={() => setDanmakuOpen((v) => !v)} + onToggleDanmaku={toggleDanmakuOpen} onDanmakuLoaded={danmakuLoaded} onDanmakuCandidates={danmakuGotCandidates} + hasPrevEpisode={Boolean(prevEpisode)} + hasNextEpisode={Boolean(nextEpisode)} + onPrevEpisode={handlePrevEpisode} + onNextEpisode={handleNextEpisode} + prevEpisodeTitle={prevEpisodeTitle} + nextEpisodeTitle={nextEpisodeTitle} + playlistOpen={playlistOpen} + hasPlaylist={playlistEpisodes.length > 0} + onTogglePlaylist={togglePlaylistOpen} + playlistPanel={ + setPlaylistOpen(false)} + currentMediaId={media?.id ?? ''} + episodes={playlistEpisodes} + onSelectEpisode={playEpisode} + /> + } danmakuPanel={ ) } + +function formatEpisodeDisplay(ep: Media, siblings: Media[]): string { + const title = ep.episode_title?.trim() + if (title && !looksLikeSeriesTitle(ep, title, siblings)) { + return title + } + + const mediaTitle = ep.title?.trim() + if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) { + return mediaTitle + } + + return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名' +} + +function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean { + const normalized = normalizeEpisodeTitle(title) + if (!normalized) return true + if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true + const pathTitle = seriesTitleFromPath(ep.path) + if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true + + const siblingTitles = new Set( + siblings + .map((item) => normalizeEpisodeTitle(item.title)) + .filter(Boolean), + ) + return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1 +} + +function normalizeEpisodeTitle(value?: string): string { + return (value ?? '') + .toLowerCase() + .replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ') + .replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ') + .replace(/[\s._-]+/g, ' ') + .trim() +} diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index e3cd819..28a8c74 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -28,6 +28,17 @@ type PlayerVideoStageProps = { onDanmakuCandidates: (candidates: DanmakuAnime[]) => void /** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */ danmakuPanel: ReactNode + /** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */ + playlistPanel?: ReactNode + hasPrevEpisode?: boolean + hasNextEpisode?: boolean + onPrevEpisode?: () => void + onNextEpisode?: () => void + prevEpisodeTitle?: string + nextEpisodeTitle?: string + playlistOpen?: boolean + hasPlaylist?: boolean + onTogglePlaylist?: () => void } export function PlayerVideoStage({ @@ -49,6 +60,16 @@ export function PlayerVideoStage({ onDanmakuLoaded, onDanmakuCandidates, danmakuPanel, + playlistPanel, + hasPrevEpisode, + hasNextEpisode, + onPrevEpisode, + onNextEpisode, + prevEpisodeTitle, + nextEpisodeTitle, + playlistOpen, + hasPlaylist, + onTogglePlaylist, }: PlayerVideoStageProps) { const stageRef = useRef(null) const [videoRatio, setVideoRatio] = useState(null) @@ -114,10 +135,11 @@ export function PlayerVideoStage({ setActiveCueText('') return } + const trackIdx = subtitleIndex const updateCue = () => { const trackEls = Array.from(video.querySelectorAll('track')) - const selectedEl = trackEls[subtitleIndex] + const selectedEl = trackEls[trackIdx] const tt = selectedEl?.track if (!tt) { setActiveCueText('') @@ -151,11 +173,11 @@ export function PlayerVideoStage({ const tt = el.track if (tt) { // 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI - tt.mode = i === subtitleIndex ? 'hidden' : 'disabled' + tt.mode = i === trackIdx ? 'hidden' : 'disabled' } }) - const selected = trackEls[subtitleIndex] + const selected = trackEls[trackIdx] if (!selected) return const tt = selected.track @@ -184,7 +206,7 @@ export function PlayerVideoStage({ video.removeEventListener('seeked', updateCue) video.removeEventListener('playing', updateCue) const trackEls = Array.from(video.querySelectorAll('track')) - const selected = trackEls[subtitleIndex] + const selected = trackEls[trackIdx] if (selected) { selected.removeEventListener('load', updateCue) if (selected.track) { @@ -241,7 +263,7 @@ export function PlayerVideoStage({ src={subtitlesAPI.url(media.id, track.path)} srcLang={track.lang} label={track.label || track.lang} - default={index === subtitleIndex} + default={subtitleIndex === index} /> ))} @@ -282,8 +304,18 @@ export function PlayerVideoStage({ danmakuOpen={danmakuOpen} danmakuEnabled={danmakuEnabled} onToggleDanmaku={onToggleDanmaku} + hasPrevEpisode={hasPrevEpisode} + hasNextEpisode={hasNextEpisode} + onPrevEpisode={onPrevEpisode} + onNextEpisode={onNextEpisode} + prevEpisodeTitle={prevEpisodeTitle} + nextEpisodeTitle={nextEpisodeTitle} + playlistOpen={playlistOpen} + hasPlaylist={hasPlaylist} + onTogglePlaylist={onTogglePlaylist} /> {danmakuPanel} + {playlistPanel} ) : (

加载中…

diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 8db7465..15c2f34 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -7,6 +7,7 @@ import { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' import type { Library, Setting } from '../types' import { APIConfigsPanel } from '../components/APIConfigsPanel' +import { FFToolsPanel } from '../components/FFToolsPanel' import { AdultSettingsPanel } from './AdultSettingsPanel' import { DatabaseSettingsPanel } from './DatabaseSettingsPanel' import { RecognitionWordsPanel } from './RecognitionWordsPanel' @@ -172,6 +173,7 @@ export function SettingsPage() { {group.key === 'api-configs' && } {group.key === 'recognition-words' && } {group.key === 'adult' && } + {group.key === 'general' && refresh().catch(() => undefined)} />} {group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
{group.description &&

{group.description}

} diff --git a/web/src/pages/settingsGroupGeneral.ts b/web/src/pages/settingsGroupGeneral.ts index d8baaea..118c2f0 100644 --- a/web/src/pages/settingsGroupGeneral.ts +++ b/web/src/pages/settingsGroupGeneral.ts @@ -83,12 +83,14 @@ export const generalSettingsGroup: SettingGroup = { label: 'FFmpeg 路径', type: 'text', placeholder: 'ffmpeg', + hint: '留空时使用系统 PATH 中的 ffmpeg;也可点击上方「下载并安装」自动下载到 data 目录并自动填入,无需手动填写', }, { key: 'ffprobe.path', label: 'FFprobe 路径', type: 'text', placeholder: 'ffprobe', + hint: '留空时使用系统 PATH 中的 ffprobe;也可点击上方「下载并安装」自动下载到 data 目录并自动填入', }, { key: 'ffprobe.max_concurrent', diff --git a/web/src/pages/useLibraryAdminActions.tsx b/web/src/pages/useLibraryAdminActions.tsx index 70fcab6..1ea6cfe 100644 --- a/web/src/pages/useLibraryAdminActions.tsx +++ b/web/src/pages/useLibraryAdminActions.tsx @@ -73,8 +73,9 @@ export function useLibraryAdminActions({ toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`) reloadCurrentLibrary() } catch (err: unknown) { - const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败` - toast.error(msg) + const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error + const msg = serverErr || (err instanceof Error ? err.message : '') + toast.error(msg ? `${label}失败:${msg}` : `${label}失败`) } finally { setSeriesToolBusy('') } @@ -146,8 +147,9 @@ export function useLibraryAdminActions({ toast.success(`${label}完成:${media.title}`) reloadCurrentLibrary() } catch (err: unknown) { - const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败` - toast.error(msg) + const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error + const msg = serverErr || (err instanceof Error ? err.message : '') + toast.error(msg ? `${label}失败:${msg}` : `${label}失败`) } finally { setMovieToolBusy('') }