Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot] 28113f5fdc chore: bump version to 0.0.55 [skip ci] 2026-08-30 10:50:29 +00:00
truewhile 42b8805e94 优化 2026-08-30 18:50:15 +08:00
github-actions[bot] 51a64f41b9 chore: bump version to 0.0.54 [skip ci] 2026-08-30 09:28:16 +00:00
truewhile a7bd9a942c 优化显示 2026-08-30 17:28:00 +08:00
github-actions[bot] 97491a6175 chore: bump version to 0.0.53 [skip ci] 2026-08-29 14:16:40 +00:00
truewhile ea5cb3a130 优化 2026-08-29 22:16:21 +08:00
github-actions[bot] 921010926b chore: bump version to 0.0.52 [skip ci] 2026-08-28 14:38:24 +00:00
truewhile 7425c3d57b 优化 2026-08-28 22:38:09 +08:00
36 changed files with 1738 additions and 726 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.51
0.0.55
+1
View File
@@ -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
+2
View File
@@ -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=
+7 -1
View File
@@ -47,7 +47,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), 10)
limit, _ := strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
if limit <= 0 {
limit = 10
} else if limit > 100 {
limit = 100
}
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+4
View File
@@ -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))
+16
View File
@@ -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)})
}
}
+1 -1
View File
@@ -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
-144
View File
@@ -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)
}
+38
View File
@@ -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()))
}
}
+3 -1
View File
@@ -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
}
+5 -2
View File
@@ -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 ""
}
+298 -136
View File
@@ -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()
}
+3 -91
View File
@@ -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()
}
-57
View File
@@ -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
}
+211
View File
@@ -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()
}
+81
View File
@@ -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()
}
+8 -1
View File
@@ -57,11 +57,18 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
}
}
fetchCount := cardLimit * 4
if fetchCount < 60 {
fetchCount = 60
} else if fetchCount > 200 {
fetchCount = 200
}
for i := range out {
if out[i].Total == 0 {
continue
}
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, 60, filter)
items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, fetchCount, filter)
if err != nil {
continue
}
+70
View File
@@ -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
+1
View File
@@ -62,6 +62,7 @@ type Container struct {
Danmaku *DanmakuService
Strm *StrmService
Database *DatabaseAdminService
FFTools *FFmpegToolsService
stopCtx context.Context
stopCancel context.CancelFunc
+2
View File
@@ -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)
}
+58
View File
@@ -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
View File
@@ -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
}
}
+5 -1
View File
@@ -87,12 +87,13 @@ export interface LibraryWithPreview extends Library {
}
export const libraryAPI = {
list: (options?: { includeHidden?: boolean; withPreview?: boolean }) =>
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) =>
api
.get<LibraryWithPreview[]>('/libraries', {
params: {
...(options?.includeHidden ? { include_hidden: 1 } : {}),
...(options?.withPreview ? { with_preview: 1 } : {}),
...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}),
},
})
.then((r) => r.data),
@@ -196,6 +197,9 @@ export const mediaAPI = {
get: (id: string) => api.get<Media>(`/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>(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
+26
View File
@@ -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<FFmpegToolsStatus>('/admin/tools/ffmpeg/status').then((r) => r.data),
// installFFTools 触发后台下载安装 ffmpeg/ffprobe(自动匹配当前平台)。
installFFTools: () =>
api.post<FFmpegToolsStatus>('/admin/tools/ffmpeg/install').then((r) => r.data),
// repairAndRescrapeAll 触发「全库修复+重刮」:先从媒体路径中的
// {tmdb-N}/{bangumi-N} 占位符回填缺失/错误的外部 ID,再批量重刮整库。
// 后端异步执行,立即返回;进度通过 WS "scrape" topic 推送。
+2 -1
View File
@@ -42,6 +42,7 @@ export function ExternalPlayerButton({
<>
<button
type="button"
title={compact ? '使用外部播放器播放' : undefined}
disabled={loading}
onClick={(event) => {
event.preventDefault()
@@ -50,7 +51,7 @@ export function ExternalPlayerButton({
}}
className={
compact
? 'rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50'
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
}
>
+154
View File
@@ -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<FFmpegToolsStatus | null>(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 (
<div className="glass-panel space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-display text-lg font-bold text-ink-600">FFmpeg / FFprobe 工具</div>
<div className="text-xs text-sand-500">
当前平台:{targetLabel} · 安装目录:
<span className="font-mono">{status?.install_dir ?? '…'}</span>
</div>
</div>
<button
type="button"
onClick={install}
disabled={busy}
className="neon-button shrink-0 disabled:opacity-50"
>
{busy ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
{busy ? '下载安装中…' : '下载并安装'}
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
<ToolRow name="FFmpeg" info={status?.ffmpeg} />
<ToolRow name="FFprobe" info={status?.ffprobe} />
</div>
{(status?.message || status?.error) && (
<div
className={
status?.error
? 'rounded-xl bg-red-50 px-3 py-2 text-xs text-red-600'
: 'rounded-xl bg-sand-100/70 px-3 py-2 text-xs text-sand-500'
}
>
{status?.error || status?.message}
</div>
)}
{!ffmpegInstalled || !ffprobeInstalled ? (
<div className="text-xs text-sand-500">
未检测到可用工具。点击「下载并安装」后,服务端会自动匹配当前系统下载对应版本,安装完成后无需手动填写路径。
</div>
) : null}
</div>
)
}
function ToolRow({ name, info }: { name: string; info?: FFmpegToolInfo }) {
return (
<div className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white/70 px-3 py-2">
{info?.installed ? (
<CheckCircle2 size={16} className="shrink-0 text-green-500" />
) : (
<XCircle size={16} className="shrink-0 text-red-400" />
)}
<div className="min-w-0">
<div className="text-sm font-medium text-ink-600">
{name} {info?.installed ? '' : '(未安装)'}
</div>
{info?.installed && (
<div className="truncate font-mono text-[11px] text-ink-50" title={info.path}>
{info.path}
</div>
)}
{info?.version && <div className="truncate text-[11px] text-sand-500">{info.version}</div>}
</div>
</div>
)
}
+86 -8
View File
@@ -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()}
>
<div className="flex items-center gap-2.5 text-white">
{/* 上一集 */}
<button
onClick={onPrevEpisode}
disabled={!hasPrevEpisode}
className={`rounded-full p-1.5 transition ${
hasPrevEpisode
? 'hover:bg-white/15 text-white cursor-pointer'
: 'text-white/30 cursor-not-allowed opacity-40'
}`}
title={hasPrevEpisode ? (prevEpisodeTitle ? `上一集:${prevEpisodeTitle} ([)` : '上一集 ([)') : '没有上一集'}
>
<SkipBack size={18} />
</button>
{/* 播放 / 暂停 */}
<button
onClick={togglePlay}
className="rounded-full p-1.5 transition hover:bg-white/15"
title={playing ? '暂停' : '播放'}
title={playing ? '暂停 (Space)' : '播放 (Space)'}
>
{playing ? <Pause size={20} /> : <Play size={20} />}
</button>
{/* 下一集 */}
<button
onClick={onNextEpisode}
disabled={!hasNextEpisode}
className={`rounded-full p-1.5 transition ${
hasNextEpisode
? 'hover:bg-white/15 text-white cursor-pointer'
: 'text-white/30 cursor-not-allowed opacity-40'
}`}
title={hasNextEpisode ? (nextEpisodeTitle ? `下一集:${nextEpisodeTitle} (])` : '下一集 (])') : '没有下一集'}
>
<SkipForward size={18} />
</button>
<input
type="range"
min={0}
@@ -383,6 +440,27 @@ export function PlayerControls({
</div>
)}
{/* 选集 / 播放列表按钮 */}
{onTogglePlaylist && (
<button
onClick={onTogglePlaylist}
disabled={!hasPlaylist}
className={
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
(!hasPlaylist
? 'bg-white/5 text-white/30 cursor-not-allowed opacity-50'
: playlistOpen
? 'bg-rose-500 text-white hover:bg-rose-600'
: 'bg-white/10 text-white/80 hover:bg-white/20')
}
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
>
<ListVideo size={15} />
选集
</button>
)}
{/* 弹幕按钮 */}
<button
onClick={onToggleDanmaku}
className={
@@ -401,7 +479,7 @@ export function PlayerControls({
<button
onClick={toggleMute}
className="rounded-full p-1.5 transition hover:bg-white/15"
title={muted || volume === 0 ? '取消静音' : '静音'}
title={muted || volume === 0 ? '取消静音 (M)' : '静音 (M)'}
>
{muted || volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button>
@@ -419,7 +497,7 @@ export function PlayerControls({
<button
onClick={toggleFullscreen}
className="rounded-full p-1.5 transition hover:bg-white/15"
title={fullscreen ? '退出全屏' : '全屏'}
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
>
{fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
+296
View File
@@ -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<HTMLDivElement | null>(null)
const listContainerRef = useRef<HTMLDivElement | null>(null)
// 按季分组
const seasonGroups = useMemo<SeasonGroup[]>(() => {
if (!episodes || episodes.length === 0) return []
const seasonsMap = new Map<number, Media[]>()
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<number>(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 (
<div
onClick={(e) => 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"
>
{/* 头部 */}
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<ListVideo size={17} className="text-rose-400" />
<span>选集列表</span>
<span className="font-mono text-xs font-normal text-white/50">
({episodes.length} 集)
</span>
</div>
<button
onClick={onClose}
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
title="关闭"
>
<X size={16} />
</button>
</div>
{/* 季选择 Tabs(若有多季) */}
{seasonGroups.length > 1 && (
<div className="flex items-center gap-1.5 border-b border-white/10 px-3 py-2 shrink-0 overflow-x-auto no-scrollbar">
{seasonGroups.map(({ season, episodes: sesEps }) => {
const isSelected = selectedSeason === season
const isPlayingThisSeason = sesEps.some((e) => e.id === currentMediaId)
return (
<button
key={season}
onClick={() => {
setSelectedSeason(season)
setFilterText('')
}}
className={`relative flex shrink-0 items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition ${
isSelected
? 'bg-rose-500 text-white'
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
}`}
>
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
<span className="text-[10px] opacity-75">({sesEps.length})</span>
{isPlayingThisSeason && !isSelected && (
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
)}
</button>
)
})}
</div>
)}
{/* 搜索/过滤单集(当单集数量较多时) */}
{listToDisplay.length > 10 && (
<div className="px-3 pt-2.5 pb-1.5 shrink-0">
<div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
<Search size={13} className="text-white/40 shrink-0" />
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="搜索集数或标题…"
className="w-full bg-transparent outline-none placeholder:text-white/30 text-white text-xs"
/>
{filterText && (
<button
onClick={() => setFilterText('')}
className="text-white/40 hover:text-white"
>
<X size={12} />
</button>
)}
</div>
</div>
)}
{/* 集数列表 */}
<div
ref={listContainerRef}
className="flex-1 overflow-y-auto p-2.5 space-y-1.5 pr-2 select-none"
>
{filteredEpisodes.length === 0 ? (
<div className="py-8 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
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 (
<div
key={ep.id}
ref={isPlaying ? activeItemRef : null}
onClick={() => 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'
}`}
>
{/* 封面/集号 */}
<div className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10 text-xs font-semibold">
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<Film size={16} className="text-white/40" />
)}
{/* 正在播放动效 / 集数徽标 */}
{isPlaying ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-xs">
<div className="flex items-end gap-0.5 h-3">
<span className="w-0.5 bg-rose-400 animate-pulse h-full" />
<span className="w-0.5 bg-rose-400 animate-pulse h-2" />
<span className="w-0.5 bg-rose-400 animate-pulse h-3" />
</div>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Play size={14} className="text-white fill-white" />
</div>
)}
{/* 角标显示集数 */}
<span className="absolute bottom-0.5 right-1 rounded bg-black/75 px-1 py-0.2 text-[9px] font-mono text-white/90">
{ep.episode_num > 0 ? `${ep.episode_num}` : '—'}
</span>
</div>
{/* 标题 & 时长 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p
className={`truncate text-xs font-medium ${
isPlaying ? 'text-rose-300 font-semibold' : 'group-hover:text-white'
}`}
>
{displayTitle}
</p>
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-white/50">
{ep.episode_num > 0 && (
<span className="font-mono">第 {ep.episode_num} 集</span>
)}
{durationText && <span>{durationText}</span>}
</div>
</div>
{isPlaying && (
<div className="shrink-0 flex items-center gap-1 text-[11px] font-medium text-rose-400 px-1">
<Check size={13} />
</div>
)}
</div>
)
})
)}
</div>
</div>
)
}
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()
}
+15 -45
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { libraryAPI } from '../api/library'
import { libraryAPI, type LibraryWithPreview } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
import type { SeriesCard } from '../utils/groupSeries'
import {
ContinueWatchingSection,
HomeCarouselSection,
@@ -29,56 +29,26 @@ export function HomePage() {
setLoading(true)
try {
const [libs, hist] = await Promise.all([
libraryAPI.list().then((rows) => asArray<Library>(rows)).catch(() => [] as Library[]),
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(rows)).catch(() => [] as HistoryItem[]),
libraryAPI
.list({ withPreview: true, previewLimit: 20 })
.then((rows) => asArray<LibraryWithPreview>(rows))
.catch(() => [] as LibraryWithPreview[]),
playbackAPI
.recentHistory()
.then((rows) => asArray<HistoryItem>(rows))
.catch(() => [] as HistoryItem[]),
])
if (cancelled) return
setLibraries(libs)
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
// Fetch media items for all libraries in parallel
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
const results = await Promise.allSettled(
libs.map(async (lib) => {
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
// 改用 /series 分页拉取全部聚合后的剧集卡片。
if (isSeriesType(lib.type)) {
const cards: SeriesCard[] = []
let total = 0
const pageSize = 200
for (let page = 1; page <= 10; page++) {
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
const pageItems = asArray<SeriesCard>(data?.items)
cards.push(...pageItems)
total = data?.total ?? cards.length
if (cards.length >= total || pageItems.length < pageSize) break
}
return { id: lib.id, cards, items: [], total }
}
const page = await libraryAPI.listMedia(lib.id, 1, 30)
const items = asArray<Media>(page?.items)
const cards = groupSeries(items)
return {
id: lib.id,
cards,
items,
total: page?.total ?? items.length,
}
}),
)
if (cancelled) return
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
for (const res of results) {
if (res.status === 'fulfilled' && res.value) {
mapData[res.value.id] = {
cards: res.value.cards,
items: res.value.items,
total: res.value.total,
}
for (const lib of libs) {
mapData[lib.id] = {
cards: lib.cards ?? [],
items: [],
total: lib.total ?? 0,
}
}
setLibraryData(mapData)
+66 -41
View File
@@ -62,40 +62,53 @@ export function LibrarySeriesEpisodes({
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
<div
key={ep.id}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
ep.episode_num || '—'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{episodeDisplayTitle(ep, visibleEpisodes)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
</div>
))}
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{visibleEpisodes.map((ep) => {
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
return (
<div
key={ep.id}
className="group flex items-center justify-between gap-3 rounded-xl border border-sand-200 bg-white p-2.5 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
<div
className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-brand-50/70 border border-sand-200/60"
title={displayTitle}
>
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
alt=""
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Play size={15} className="fill-white text-white drop-shadow-sm" />
</div>
{ep.episode_num > 0 && (
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
{ep.episode_num}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600 transition-colors group-hover:text-brand-600" title={displayTitle}>
{displayTitle}
</p>
<p className="text-xs text-sand-500 whitespace-nowrap">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
</Link>
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
</div>
)
})}
</div>
</div>
</>
@@ -103,17 +116,29 @@ export function LibrarySeriesEpisodes({
}
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
const title = ep.episode_title?.trim()
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
return title
let mainTitle = ''
const epTitle = ep.episode_title?.trim()
if (epTitle && !looksLikeSeriesTitle(ep, epTitle, siblings)) {
mainTitle = epTitle
} else {
const mediaTitle = ep.title?.trim()
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
mainTitle = mediaTitle
}
}
const mediaTitle = ep.title?.trim()
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
return mediaTitle
if (ep.episode_num > 0) {
if (!mainTitle) {
return `第 ${ep.episode_num} 集`
}
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
if (prefixRegex.test(mainTitle)) {
return mainTitle
}
return `第 ${ep.episode_num} 集 · ${mainTitle}`
}
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
return mainTitle || '未命名'
}
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
+214 -7
View File
@@ -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<Media[]>([])
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={
<PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
/>
}
danmakuPanel={
<PlayerDanmakuPanel
open={danmakuOpen}
@@ -373,3 +542,41 @@ export function PlayerPage() {
</div>
)
}
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()
}
+37 -5
View File
@@ -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<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(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}
/>
))}
</video>
@@ -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}
</>
) : (
<p className="text-sand-500">加载中…</p>
+2
View File
@@ -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' && <APIConfigsPanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.key === 'adult' && <AdultSettingsPanel />}
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
+2
View File
@@ -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',
+6 -4
View File
@@ -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('')
}