From a8395c9de7401587c709d75ebdc42ff69b67fa8c Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Fri, 29 May 2026 13:54:19 +0800 Subject: [PATCH] fix: enforce transcoding resource controls --- .env.example | 8 ++ README.md | 38 ++++--- README_EN.md | 24 +++-- config.example.yaml | 6 ++ docker-compose.yml | 15 ++- internal/config/config.go | 26 +++-- internal/handler/admin.go | 6 ++ internal/handler/streaming.go | 8 ++ internal/handler/system_extra.go | 6 +- internal/service/runtime_settings.go | 29 ++++++ internal/service/runtime_settings_test.go | 33 ++++++ internal/service/stream.go | 2 + internal/service/transcoder.go | 121 ++++++++++++++++++++-- internal/service/transcoder_test.go | 41 +++++++- web/src/pages/PlayerPage.tsx | 9 +- web/src/pages/SettingsPage.tsx | 46 ++++++-- 16 files changed, 363 insertions(+), 55 deletions(-) create mode 100644 internal/service/runtime_settings_test.go diff --git a/.env.example b/.env.example index a489f10..e314010 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,11 @@ ADMIN_INITIAL_PASSWORD=admin123 # 3rd-party scrape providers. # MEDIASTATION_SECRETS_TMDB_API_KEY= # MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN= + +# HLS transcoding guardrails. Defaults favor low CPU on NAS devices. +# MEDIASTATION_TRANSCODER_ENABLED=true +# MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=false +# MEDIASTATION_TRANSCODER_REALTIME=true +# MEDIASTATION_TRANSCODER_THREADS=2 +# MEDIASTATION_TRANSCODER_MAX_CONCURRENT=1 +# MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS=120 diff --git a/README.md b/README.md index 090cc66..932a63f 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -307,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -402,8 +402,14 @@ services: MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} # 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。 + MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true} + MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: ${MEDIASTATION_TRANSCODER_HARDWARE_ACCEL:-false} MEDIASTATION_TRANSCODER_ENCODER: ${MEDIASTATION_TRANSCODER_ENCODER:-} MEDIASTATION_TRANSCODER_MAX_HEIGHT: ${MEDIASTATION_TRANSCODER_MAX_HEIGHT:-1080} + MEDIASTATION_TRANSCODER_REALTIME: ${MEDIASTATION_TRANSCODER_REALTIME:-true} + MEDIASTATION_TRANSCODER_THREADS: ${MEDIASTATION_TRANSCODER_THREADS:-2} + MEDIASTATION_TRANSCODER_MAX_CONCURRENT: ${MEDIASTATION_TRANSCODER_MAX_CONCURRENT:-1} + MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: ${MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS:-120} # 跨域来源。通常无需设置;反向代理或三端客户端异常时再按需填写。 MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-} @@ -415,7 +421,8 @@ services: # Intel QSV / VAAPI 硬件加速示例: # 1. Linux/NAS 宿主机存在 /dev/dri。 - # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_ENCODER=vaapi。 + # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=true + # 和 MEDIASTATION_TRANSCODER_ENCODER=vaapi。 # 3. 取消下方 devices/group_add 注释。 # devices: # - /dev/dri:/dev/dri @@ -424,7 +431,8 @@ services: # NVIDIA NVENC 硬件加速示例: # 1. 宿主机安装 NVIDIA Container Toolkit。 - # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_ENCODER=nvenc。 + # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=true + # 和 MEDIASTATION_TRANSCODER_ENCODER=nvenc。 # 3. 取消下方 gpus 注释。 # gpus: all @@ -508,7 +516,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -771,26 +779,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.15-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.15-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.15-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.15-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.15-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.16-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.16-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.16-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.16-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.16-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.15-linux-amd64.tar.gz -cd MediaStationGo-v0.0.15-linux-amd64 +tar -xzf MediaStationGo-v0.0.16-linux-amd64.tar.gz +cd MediaStationGo-v0.0.16-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.15-windows-amd64.zip -cd .\MediaStationGo-v0.0.15-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.16-windows-amd64.zip +cd .\MediaStationGo-v0.0.16-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` @@ -947,6 +955,8 @@ MediaStationGo 不会把 `ffmpeg` 或 `ffprobe` 作为常驻守护进程启动 播放停止、转码任务取消或服务退出时,后台会结束对应转码任务。空闲状态下如果没有扫描、探测或转码,`ffmpeg/ffprobe` 不应持续占用 CPU。 +默认 HLS 转码使用 NAS 友好的低负载策略:`MEDIASTATION_TRANSCODER_ENABLED=true` 是总开关,关闭后不会启动 ffmpeg 转码;`MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=false` 是硬件加速总开关,只有开启后才会使用 `MEDIASTATION_TRANSCODER_ENCODER=nvenc/qsv/vaapi`;`MEDIASTATION_TRANSCODER_REALTIME=true` 按播放速度处理输入,`MEDIASTATION_TRANSCODER_THREADS=2` 限制软件编码线程,`MEDIASTATION_TRANSCODER_MAX_CONCURRENT=1` 限制同时转码数量,`MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS=120` 在播放器停止请求分片后自动结束 ffmpeg。 + --- ## 🔍 刮削与元数据策略 diff --git a/README_EN.md b/README_EN.md index f5ee6e9..9722ee9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -235,7 +235,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -593,25 +593,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.15-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.15-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.15-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.15-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.15-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.16-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.16-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.16-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.16-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.16-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.15-linux-amd64.tar.gz -cd MediaStationGo-v0.0.15-linux-amd64 +tar -xzf MediaStationGo-v0.0.16-linux-amd64.tar.gz +cd MediaStationGo-v0.0.16-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.15-windows-amd64.zip -cd .\MediaStationGo-v0.0.15-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.16-windows-amd64.zip +cd .\MediaStationGo-v0.0.16-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` @@ -768,6 +768,8 @@ MediaStationGo does not keep `ffmpeg` or `ffprobe` running as resident daemons. When playback stops, a transcode job is cancelled, or the service shuts down, the corresponding transcoding process is stopped. If there is no scanning, probing, or transcoding, `ffmpeg/ffprobe` should not continuously consume CPU. +The default HLS profile is NAS-friendly: `MEDIASTATION_TRANSCODER_ENABLED=true` is the global switch, and disabling it prevents ffmpeg transcode jobs from starting; `MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=false` is the hardware acceleration switch, and hardware encoders are used only when it is enabled together with `MEDIASTATION_TRANSCODER_ENCODER=nvenc/qsv/vaapi`; `MEDIASTATION_TRANSCODER_REALTIME=true` throttles input to playback speed, `MEDIASTATION_TRANSCODER_THREADS=2` caps software encoding threads, `MEDIASTATION_TRANSCODER_MAX_CONCURRENT=1` limits simultaneous transcodes, and `MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS=120` stops ffmpeg after the player stops requesting segments. + --- ## 🔍 Metadata Strategy diff --git a/config.example.yaml b/config.example.yaml index 46dad8f..156201a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -70,6 +70,8 @@ transcoder: # nvenc h264_nvenc (NVIDIA GPU, requires --gpus all) # qsv h264_qsv (Intel iGPU, requires /dev/dri) # vaapi h264_vaapi (Intel/AMD VAAPI, requires /dev/dri) + enabled: true # global HLS transcode switch; false = direct play only and no ffmpeg transcode jobs + hardware_accel: false # must be true before encoder below is used encoder: "" preset: veryfast video_bitrate: 1500k @@ -77,6 +79,10 @@ transcoder: buf_size: 3000k max_height: 720 segment_seconds: 4 + realtime: true # throttle ffmpeg input to playback speed instead of transcoding the whole file at full speed + threads: 2 # software libx264 thread cap; set 0 to let ffmpeg decide + max_concurrent: 1 # maximum simultaneous HLS transcodes + idle_timeout_seconds: 120 # stop ffmpeg when the player stops requesting HLS segments ai: enabled: false diff --git a/docker-compose.yml b/docker-compose.yml index 8fae300..4c46c1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -128,8 +128,15 @@ services: MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} # 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。 + MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true} + MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: ${MEDIASTATION_TRANSCODER_HARDWARE_ACCEL:-false} MEDIASTATION_TRANSCODER_ENCODER: ${MEDIASTATION_TRANSCODER_ENCODER:-} MEDIASTATION_TRANSCODER_MAX_HEIGHT: ${MEDIASTATION_TRANSCODER_MAX_HEIGHT:-1080} + # NAS 低负载保护:HLS 按播放速度转码、限制软件编码线程、限制并发、空闲自动停止。 + MEDIASTATION_TRANSCODER_REALTIME: ${MEDIASTATION_TRANSCODER_REALTIME:-true} + MEDIASTATION_TRANSCODER_THREADS: ${MEDIASTATION_TRANSCODER_THREADS:-2} + MEDIASTATION_TRANSCODER_MAX_CONCURRENT: ${MEDIASTATION_TRANSCODER_MAX_CONCURRENT:-1} + MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: ${MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS:-120} # 跨域来源。通常无需设置;反向代理或三端客户端异常时再按需填写。 MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-} @@ -141,7 +148,8 @@ services: # Intel QSV / VAAPI 硬件加速示例: # 1. Linux/NAS 宿主机存在 /dev/dri。 - # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_ENCODER=vaapi。 + # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=true + # 和 MEDIASTATION_TRANSCODER_ENCODER=vaapi。 # 3. 取消下方 devices/group_add 注释。 # devices: # - /dev/dri:/dev/dri @@ -150,7 +158,8 @@ services: # NVIDIA NVENC 硬件加速示例: # 1. 宿主机安装 NVIDIA Container Toolkit。 - # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_ENCODER=nvenc。 + # 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=true + # 和 MEDIASTATION_TRANSCODER_ENCODER=nvenc。 # 3. 取消下方 gpus 注释。 # gpus: all diff --git a/internal/config/config.go b/internal/config/config.go index 1339473..fa3c29e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,13 +47,19 @@ type ApiConfigConfig struct { // TranscoderConfig 控制 HLS / ffmpeg 后端。 type TranscoderConfig struct { - Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi - Preset string `mapstructure:"preset"` - VideoBitrate string `mapstructure:"video_bitrate"` - MaxRate string `mapstructure:"max_rate"` - BufSize string `mapstructure:"buf_size"` - MaxHeight int `mapstructure:"max_height"` - SegmentSeconds int `mapstructure:"segment_seconds"` + Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi + Enabled bool `mapstructure:"enabled"` + HardwareAccel bool `mapstructure:"hardware_accel"` + Preset string `mapstructure:"preset"` + VideoBitrate string `mapstructure:"video_bitrate"` + MaxRate string `mapstructure:"max_rate"` + BufSize string `mapstructure:"buf_size"` + MaxHeight int `mapstructure:"max_height"` + SegmentSeconds int `mapstructure:"segment_seconds"` + Realtime bool `mapstructure:"realtime"` + Threads int `mapstructure:"threads"` + MaxConcurrent int `mapstructure:"max_concurrent"` + IdleTimeoutSeconds int `mapstructure:"idle_timeout_seconds"` } // AppConfig 保存运行时应用参数。 @@ -259,12 +265,18 @@ func setDefaults(v *viper.Viper) { v.SetDefault("organizer.categories.uncategorized_tv", "未分类") v.SetDefault("transcoder.encoder", "") + v.SetDefault("transcoder.enabled", true) + v.SetDefault("transcoder.hardware_accel", false) v.SetDefault("transcoder.preset", "veryfast") v.SetDefault("transcoder.video_bitrate", "1500k") v.SetDefault("transcoder.max_rate", "1800k") v.SetDefault("transcoder.buf_size", "3000k") v.SetDefault("transcoder.max_height", 720) v.SetDefault("transcoder.segment_seconds", 4) + v.SetDefault("transcoder.realtime", true) + v.SetDefault("transcoder.threads", 2) + v.SetDefault("transcoder.max_concurrent", 1) + v.SetDefault("transcoder.idle_timeout_seconds", 120) // API Config 默认设置 v.SetDefault("api_config.auto_encrypt", true) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index fdbd0ae..3598b21 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -187,6 +187,12 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { return } service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value) + if req.Key == "transcode.enabled" && !svc.Cfg.Transcoder.Enabled { + svc.Transcoder.StopAll() + } + if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" { + svc.Transcoder.StopAll() + } c.Status(http.StatusNoContent) } } diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 340b720..59e7200 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -18,6 +18,14 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + if errors.Is(err, service.ErrTranscodeDisabled) { + c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"}) + return + } + if errors.Is(err, service.ErrTranscodeBusy) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "transcode busy"}) + return + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 7ebe497..4cb9067 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -64,8 +64,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "items": []gin.H{ {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"}, {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"}, - {"key": "transcode.hw_accel", "type": "select", "label": "硬件加速"}, + {"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"}, + {"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"}, {"key": "transcode.max_jobs", "type": "number", "label": "最大并发"}, + {"key": "transcode.realtime", "type": "toggle", "label": "按播放速度转码"}, + {"key": "transcode.threads", "type": "number", "label": "软件转码线程数"}, + {"key": "transcode.idle_timeout_seconds", "type": "number", "label": "转码空闲停止秒数"}, {"key": "ffmpeg.path", "type": "text", "label": "FFmpeg 路径"}, {"key": "ffprobe.path", "type": "text", "label": "FFprobe 路径"}, }, diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index c9f11ce..8ffea2a 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -37,6 +37,10 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { cfg.App.FFmpegPath = value case "ffprobe.path", "app.ffprobe_path": cfg.App.FFprobePath = value + case "transcode.enabled", "transcoder.enabled": + cfg.Transcoder.Enabled = parseBoolSetting(value, true) + case "transcode.hw_enabled", "transcoder.hardware_accel": + cfg.Transcoder.HardwareAccel = parseBoolSetting(value, false) case "transcode.hw_accel", "transcoder.encoder": switch value { case "", "auto", "none", "software": @@ -48,6 +52,20 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { if n, err := strconv.Atoi(value); err == nil { cfg.Transcoder.MaxHeight = n } + case "transcode.max_jobs", "transcoder.max_concurrent": + if n, err := strconv.Atoi(value); err == nil { + cfg.Transcoder.MaxConcurrent = n + } + case "transcode.realtime", "transcoder.realtime": + cfg.Transcoder.Realtime = parseBoolSetting(value, true) + case "transcode.threads", "transcoder.threads": + if n, err := strconv.Atoi(value); err == nil { + cfg.Transcoder.Threads = n + } + case "transcode.idle_timeout_seconds", "transcoder.idle_timeout_seconds": + if n, err := strconv.Atoi(value); err == nil { + cfg.Transcoder.IdleTimeoutSeconds = n + } case "transcode.video_bitrate", "transcoder.video_bitrate": cfg.Transcoder.VideoBitrate = value case "license.server_url": @@ -56,3 +74,14 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { cfg.License.HMACSecret = value } } + +func parseBoolSetting(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on", "enabled", "启用", "开启": + return true + case "0", "false", "no", "off", "disabled", "禁用", "关闭": + return false + default: + return fallback + } +} diff --git a/internal/service/runtime_settings_test.go b/internal/service/runtime_settings_test.go new file mode 100644 index 0000000..4987bc3 --- /dev/null +++ b/internal/service/runtime_settings_test.go @@ -0,0 +1,33 @@ +package service + +import ( + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +func TestApplyRuntimeSettingTranscodeSwitches(t *testing.T) { + cfg := &config.Config{} + cfg.Transcoder.Enabled = true + cfg.Transcoder.HardwareAccel = false + + ApplyRuntimeSetting(cfg, "transcode.enabled", "false") + if cfg.Transcoder.Enabled { + t.Fatal("transcode.enabled=false should disable transcoding") + } + + ApplyRuntimeSetting(cfg, "transcode.hw_enabled", "true") + if !cfg.Transcoder.HardwareAccel { + t.Fatal("transcode.hw_enabled=true should enable hardware accel") + } + + ApplyRuntimeSetting(cfg, "transcode.hw_accel", "nvenc") + if cfg.Transcoder.Encoder != "nvenc" { + t.Fatalf("encoder = %q, want nvenc", cfg.Transcoder.Encoder) + } + + ApplyRuntimeSetting(cfg, "transcode.max_jobs", "1") + if cfg.Transcoder.MaxConcurrent != 1 { + t.Fatalf("max concurrent = %d, want 1", cfg.Transcoder.MaxConcurrent) + } +} diff --git a/internal/service/stream.go b/internal/service/stream.go index 3ef63c3..1596511 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -94,6 +94,7 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil { return err } + s.transcoder.TouchJob(mediaID) if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) { return errors.New("hls playlist not ready") } @@ -145,6 +146,7 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string { // ServeHLSSegment writes a single .ts segment from the on-disk cache. func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error { + s.transcoder.TouchJob(mediaID) // Only allow segments that look like seg_NNNNN.ts so we cannot be tricked // into reading arbitrary files via path traversal. if !strings.HasPrefix(segment, "seg_") || !strings.HasSuffix(segment, ".ts") { diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 0f8f1dc..6db8643 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -29,6 +29,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "sync" "time" @@ -56,10 +57,19 @@ type hlsJob struct { outputDir string cancel context.CancelFunc startedAt time.Time + lastAccess time.Time playlistOK bool encoder string } +var ( + // ErrTranscodeDisabled is returned when HLS transcoding is globally disabled. + ErrTranscodeDisabled = errors.New("transcode disabled") + // ErrTranscodeBusy is returned when the server has reached its configured + // ffmpeg concurrency limit. + ErrTranscodeBusy = errors.New("transcode concurrency limit reached") +) + // NewTranscoderService is the constructor. func NewTranscoderService(cfg *config.Config, log *zap.Logger, repo *repository.Container, hub *Hub) *TranscoderService { return &TranscoderService{ @@ -85,6 +95,9 @@ func (t *TranscoderService) PlaylistPath(mediaID string) string { // non-blocking: it returns the playlist path immediately. The caller is // expected to poll until WaitReady reports true. func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) { + if !t.cfg.Transcoder.Enabled { + return "", ErrTranscodeDisabled + } m, err := t.repo.Media.FindByID(ctx, mediaID) if err != nil { return "", err @@ -101,9 +114,14 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri t.mu.Lock() if _, ok := t.jobs[mediaID]; ok { + t.touchJobLocked(mediaID) t.mu.Unlock() return t.PlaylistPath(mediaID), nil } + if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max { + t.mu.Unlock() + return "", ErrTranscodeBusy + } outDir := t.HLSDir(mediaID) if err := os.MkdirAll(outDir, 0o755); err != nil { @@ -113,15 +131,17 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri jobCtx, cancel := context.WithCancel(context.Background()) job := &hlsJob{ - mediaID: mediaID, - outputDir: outDir, - cancel: cancel, - startedAt: time.Now(), - encoder: t.cfg.Transcoder.Encoder, + mediaID: mediaID, + outputDir: outDir, + cancel: cancel, + startedAt: time.Now(), + lastAccess: time.Now(), + encoder: t.effectiveEncoder(), } t.jobs[mediaID] = job t.mu.Unlock() + go t.monitorIdle(jobCtx, job) go t.runFFmpeg(jobCtx, job, m.Path) return t.PlaylistPath(mediaID), nil } @@ -160,6 +180,21 @@ func (t *TranscoderService) StopJob(mediaID string) { } } +// TouchJob records client activity for the HLS playlist or segment. The idle +// watchdog uses it to stop ffmpeg soon after the player is closed or switches +// back to direct play. +func (t *TranscoderService) TouchJob(mediaID string) { + t.mu.Lock() + defer t.mu.Unlock() + t.touchJobLocked(mediaID) +} + +func (t *TranscoderService) touchJobLocked(mediaID string) { + if j, ok := t.jobs[mediaID]; ok { + j.lastAccess = time.Now() + } +} + // StopAll terminates every running transcode (called on graceful shutdown). func (t *TranscoderService) StopAll() { t.mu.Lock() @@ -194,6 +229,51 @@ func (t *TranscoderService) Active() []ActiveJob { return out } +func (t *TranscoderService) maxConcurrent() int { + if t.cfg.Transcoder.MaxConcurrent <= 0 { + return 1 + } + return t.cfg.Transcoder.MaxConcurrent +} + +func (t *TranscoderService) idleTimeout() time.Duration { + if t.cfg.Transcoder.IdleTimeoutSeconds <= 0 { + return 120 * time.Second + } + return time.Duration(t.cfg.Transcoder.IdleTimeoutSeconds) * time.Second +} + +func (t *TranscoderService) monitorIdle(ctx context.Context, job *hlsJob) { + timeout := t.idleTimeout() + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + t.mu.Lock() + current, ok := t.jobs[job.mediaID] + if !ok { + t.mu.Unlock() + return + } + idleFor := time.Since(current.lastAccess) + t.mu.Unlock() + if idleFor >= timeout { + t.log.Info("transcode idle timeout", + zap.String("media_id", job.mediaID), + zap.Duration("idle_for", idleFor), + zap.Duration("timeout", timeout), + ) + t.StopJob(job.mediaID) + return + } + } + } +} + func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) { bin, err := t.resolveFFmpegPath() if err != nil { @@ -249,7 +329,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s func (t *TranscoderService) resolveFFmpegPath() (string, error) { var lastErr error for _, bin := range executableCandidates(strings.TrimSpace(t.cfg.App.FFmpegPath), "ffmpeg") { - if err := validateFFmpegForTranscode(context.Background(), bin, t.cfg.Transcoder.Encoder); err != nil { + if err := validateFFmpegForTranscode(context.Background(), bin, t.effectiveEncoder()); err != nil { lastErr = err continue } @@ -262,6 +342,22 @@ func (t *TranscoderService) resolveFFmpegPath() (string, error) { return "", fmt.Errorf("ffmpeg not found in PATH or common local app directories; configure app.ffmpeg_path to an existing local ffmpeg") } +func (t *TranscoderService) effectiveEncoder() string { + if !t.cfg.Transcoder.HardwareAccel { + return "" + } + return normalizedHardwareEncoder(t.cfg.Transcoder.Encoder) +} + +func normalizedHardwareEncoder(encoder string) string { + switch strings.ToLower(strings.TrimSpace(encoder)) { + case "nvenc", "qsv", "vaapi": + return strings.ToLower(strings.TrimSpace(encoder)) + default: + return "" + } +} + func validateFFmpegForTranscode(ctx context.Context, bin, encoder string) error { required := requiredVideoEncoder(encoder) out, err := commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-encoders") @@ -314,7 +410,10 @@ func hasFFmpegListEntry(output, name string) bool { // encoder. The function is package-level so the unit test can pin its // behaviour without spawning a real ffmpeg process. func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string { - enc := cfg.Transcoder.Encoder + enc := "" + if cfg.Transcoder.HardwareAccel { + enc = normalizedHardwareEncoder(cfg.Transcoder.Encoder) + } bitrate := cfg.Transcoder.VideoBitrate if bitrate == "" { bitrate = "1500k" @@ -373,11 +472,17 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st vpreset = preset } - args := []string{"-y", "-fflags", "+genpts"} + args := []string{"-y", "-hide_banner", "-nostdin", "-fflags", "+genpts"} for _, p := range splitNonEmptyArgs(pre) { args = append(args, p) } + if cfg.Transcoder.Realtime { + args = append(args, "-re") + } args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", vf, "-c:v", vcodec) + if cfg.Transcoder.Threads > 0 && vcodec == "libx264" { + args = append(args, "-threads", strconv.Itoa(cfg.Transcoder.Threads)) + } if vpreset != "" { args = append(args, "-preset", vpreset) } diff --git a/internal/service/transcoder_test.go b/internal/service/transcoder_test.go index f95f03a..a7ccb49 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -11,6 +11,8 @@ func TestBuildFFmpegArgs(t *testing.T) { base := &config.Config{} base.Transcoder.MaxHeight = 720 base.Transcoder.SegmentSeconds = 4 + base.Transcoder.Realtime = true + base.Transcoder.Threads = 2 base.App.VAAPIDevice = "/dev/dri/renderD128" cases := []struct { @@ -20,7 +22,7 @@ func TestBuildFFmpegArgs(t *testing.T) { expectInArgs []string expectNotPresetIfBlank bool }{ - {"software", "", "libx264", []string{"-preset", "veryfast", "-c:v", "libx264"}, false}, + {"software", "", "libx264", []string{"-re", "-preset", "veryfast", "-c:v", "libx264", "-threads", "2"}, false}, {"nvenc", "nvenc", "h264_nvenc", []string{"-hwaccel", "cuda", "-c:v", "h264_nvenc", "-preset", "p4"}, false}, {"qsv", "qsv", "h264_qsv", []string{"-hwaccel", "qsv", "-c:v", "h264_qsv"}, false}, {"vaapi", "vaapi", "h264_vaapi", []string{"-hwaccel", "vaapi", "-vaapi_device", "/dev/dri/renderD128", "-c:v", "h264_vaapi"}, true}, @@ -30,6 +32,7 @@ func TestBuildFFmpegArgs(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := *base cfg.Transcoder.Encoder = tc.encoder + cfg.Transcoder.HardwareAccel = tc.encoder != "" args := buildFFmpegArgs(&cfg, "/x.mkv", "/o/x.m3u8", "/o/seg_%05d.ts") joined := strings.Join(args, " ") for _, frag := range tc.expectInArgs { @@ -45,6 +48,42 @@ func TestBuildFFmpegArgs(t *testing.T) { } } +func TestBuildFFmpegArgsIgnoresEncoderWhenHardwareAccelDisabled(t *testing.T) { + cfg := &config.Config{} + cfg.Transcoder.Encoder = "nvenc" + cfg.Transcoder.HardwareAccel = false + cfg.Transcoder.MaxHeight = 720 + cfg.Transcoder.SegmentSeconds = 4 + cfg.Transcoder.Realtime = true + cfg.Transcoder.Threads = 2 + + args := buildFFmpegArgs(cfg, "/x.mkv", "/o/x.m3u8", "/o/seg_%05d.ts") + joined := strings.Join(args, " ") + if strings.Contains(joined, "h264_nvenc") { + t.Fatalf("hardware disabled should not use nvenc, got: %s", joined) + } + if !strings.Contains(joined, "libx264") { + t.Fatalf("hardware disabled should fall back to libx264, got: %s", joined) + } +} + +func TestBuildFFmpegArgsCanDisableRealtimeAndThreadCap(t *testing.T) { + cfg := &config.Config{} + cfg.Transcoder.MaxHeight = 720 + cfg.Transcoder.SegmentSeconds = 4 + cfg.Transcoder.Realtime = false + cfg.Transcoder.Threads = 0 + + args := buildFFmpegArgs(cfg, "/x.mkv", "/o/x.m3u8", "/o/seg_%05d.ts") + joined := " " + strings.Join(args, " ") + " " + if strings.Contains(joined, " -re ") { + t.Fatalf("realtime=false should not include -re, got: %s", joined) + } + if strings.Contains(joined, " -threads ") { + t.Fatalf("threads=0 should not include -threads, got: %s", joined) + } +} + func TestRequiredVideoEncoder(t *testing.T) { cases := map[string]string{ "": "libx264", diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 38c941c..563b09a 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -5,7 +5,7 @@ import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react' import toast from 'react-hot-toast' import { mediaAPI } from '../api/library' -import { hlsURL, streamURL } from '../api/client' +import { api, hlsURL, streamURL } from '../api/client' import { playbackAPI } from '../api/playback' import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' import type { Media } from '../types' @@ -99,7 +99,7 @@ export function PlayerPage() { } void video.play().catch(() => undefined) } - return teardownHls + return () => teardownHls(media.id, mode === 'hls') }, [hlsUnavailable, media, mode, params, setParams]) // Persist resume position every 10 seconds while playing. @@ -136,11 +136,14 @@ export function PlayerPage() { return () => window.removeEventListener('keydown', onKey) }, [id, media?.id, navigate]) - const teardownHls = () => { + const teardownHls = (mediaId?: string, stopServer = false) => { if (hlsRef.current) { hlsRef.current.destroy() hlsRef.current = null } + if (stopServer && mediaId) { + api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined) + } } const goBack = () => { diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 4385c2a..9236877 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -18,6 +18,7 @@ interface SettingDef { label: string type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' hint?: string + defaultValue?: string options?: { value: string; label: string }[] placeholder?: string } @@ -51,25 +52,55 @@ const GROUPS: SettingGroup[] = [ label: '启用转码', type: 'toggle', hint: '关闭后所有视频直连播放', + defaultValue: 'true', }, { key: 'transcode.hw_accel', - label: '硬件加速', + label: '硬件编码器', type: 'select', + hint: '只有开启下方「启用硬件加速」后才会使用;未开启时强制软件转码', + defaultValue: 'none', options: [ - { value: 'auto', label: '自动检测' }, { value: 'none', label: '软件转码' }, { value: 'nvenc', label: 'NVIDIA NVENC' }, { value: 'qsv', label: 'Intel QSV' }, { value: 'vaapi', label: 'VAAPI (Linux)' }, - { value: 'videotoolbox', label: 'VideoToolbox (macOS)' }, ], }, + { + key: 'transcode.hw_enabled', + label: '启用硬件加速', + type: 'toggle', + hint: '关闭时即使选择了 NVENC/QSV/VAAPI,也不会调用硬件编码参数', + defaultValue: 'false', + }, { key: 'transcode.max_jobs', label: '最大并发转码任务', type: 'number', - hint: '建议 1-4', + hint: 'NAS 建议 1', + defaultValue: '1', + }, + { + key: 'transcode.realtime', + label: '按播放速度转码', + type: 'toggle', + hint: '开启后 ffmpeg 不会抢跑压完整片,可显著降低 CPU 峰值', + defaultValue: 'true', + }, + { + key: 'transcode.threads', + label: '软件转码线程数', + type: 'number', + hint: 'NAS 建议 1-2;仅软件转码生效', + defaultValue: '2', + }, + { + key: 'transcode.idle_timeout_seconds', + label: '转码空闲停止秒数', + type: 'number', + hint: '播放器关闭或停止请求分片后自动结束 ffmpeg', + defaultValue: '120', }, { key: 'ffmpeg.path', @@ -299,7 +330,7 @@ export function SettingsPage() { onChange(it.key, v)} /> ))} @@ -369,6 +400,7 @@ function SettingRow({ value: string onChange: (v: string) => void }) { + const toggleOn = value === 'true' || value === '1' || value === 'on' return (
)}