mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
fix: enforce transcoding resource controls
This commit is contained in:
@@ -23,3 +23,11 @@ ADMIN_INITIAL_PASSWORD=admin123
|
|||||||
# 3rd-party scrape providers.
|
# 3rd-party scrape providers.
|
||||||
# MEDIASTATION_SECRETS_TMDB_API_KEY=
|
# MEDIASTATION_SECRETS_TMDB_API_KEY=
|
||||||
# MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN=
|
# 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
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
|
|||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
|
# 固定版本;需要升级时改成新的 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
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
|
|
||||||
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
||||||
@@ -307,7 +307,7 @@ vim docker-compose.yml
|
|||||||
#
|
#
|
||||||
# 镜像版本:
|
# 镜像版本:
|
||||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15
|
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16
|
||||||
#
|
#
|
||||||
# 路径映射总览:
|
# 路径映射总览:
|
||||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||||
@@ -402,8 +402,14 @@ services:
|
|||||||
MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info}
|
MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info}
|
||||||
|
|
||||||
# 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。
|
# 转码配置。留空表示自动/软件转码;硬件加速见下方 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_ENCODER: ${MEDIASTATION_TRANSCODER_ENCODER:-}
|
||||||
MEDIASTATION_TRANSCODER_MAX_HEIGHT: ${MEDIASTATION_TRANSCODER_MAX_HEIGHT:-1080}
|
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:-}
|
MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-}
|
||||||
@@ -415,7 +421,8 @@ services:
|
|||||||
|
|
||||||
# Intel QSV / VAAPI 硬件加速示例:
|
# Intel QSV / VAAPI 硬件加速示例:
|
||||||
# 1. Linux/NAS 宿主机存在 /dev/dri。
|
# 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 注释。
|
# 3. 取消下方 devices/group_add 注释。
|
||||||
# devices:
|
# devices:
|
||||||
# - /dev/dri:/dev/dri
|
# - /dev/dri:/dev/dri
|
||||||
@@ -424,7 +431,8 @@ services:
|
|||||||
|
|
||||||
# NVIDIA NVENC 硬件加速示例:
|
# NVIDIA NVENC 硬件加速示例:
|
||||||
# 1. 宿主机安装 NVIDIA Container Toolkit。
|
# 1. 宿主机安装 NVIDIA Container Toolkit。
|
||||||
# 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_ENCODER=nvenc。
|
# 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=true
|
||||||
|
# 和 MEDIASTATION_TRANSCODER_ENCODER=nvenc。
|
||||||
# 3. 取消下方 gpus 注释。
|
# 3. 取消下方 gpus 注释。
|
||||||
# gpus: all
|
# gpus: all
|
||||||
|
|
||||||
@@ -508,7 +516,7 @@ docker compose up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -771,26 +779,26 @@ cd MediaStationGo
|
|||||||
|
|
||||||
| 平台 | 包名示例 |
|
| 平台 | 包名示例 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Linux x86_64 | `MediaStationGo-v0.0.15-linux-amd64.tar.gz` |
|
| Linux x86_64 | `MediaStationGo-v0.0.16-linux-amd64.tar.gz` |
|
||||||
| Linux ARM64 | `MediaStationGo-v0.0.15-linux-arm64.tar.gz` |
|
| Linux ARM64 | `MediaStationGo-v0.0.16-linux-arm64.tar.gz` |
|
||||||
| Windows x86_64 | `MediaStationGo-v0.0.15-windows-amd64.zip` |
|
| Windows x86_64 | `MediaStationGo-v0.0.16-windows-amd64.zip` |
|
||||||
| macOS Intel | `MediaStationGo-v0.0.15-darwin-amd64.tar.gz` |
|
| macOS Intel | `MediaStationGo-v0.0.16-darwin-amd64.tar.gz` |
|
||||||
| macOS Apple Silicon | `MediaStationGo-v0.0.15-darwin-arm64.tar.gz` |
|
| macOS Apple Silicon | `MediaStationGo-v0.0.16-darwin-arm64.tar.gz` |
|
||||||
|
|
||||||
部署步骤:
|
部署步骤:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux 示例
|
# Linux 示例
|
||||||
tar -xzf MediaStationGo-v0.0.15-linux-amd64.tar.gz
|
tar -xzf MediaStationGo-v0.0.16-linux-amd64.tar.gz
|
||||||
cd MediaStationGo-v0.0.15-linux-amd64
|
cd MediaStationGo-v0.0.16-linux-amd64
|
||||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Expand-Archive .\MediaStationGo-v0.0.15-windows-amd64.zip
|
Expand-Archive .\MediaStationGo-v0.0.16-windows-amd64.zip
|
||||||
cd .\MediaStationGo-v0.0.15-windows-amd64
|
cd .\MediaStationGo-v0.0.16-windows-amd64
|
||||||
$env:MEDIASTATION_APP_PORT = "18080"
|
$env:MEDIASTATION_APP_PORT = "18080"
|
||||||
.\mediastation-go.exe
|
.\mediastation-go.exe
|
||||||
```
|
```
|
||||||
@@ -947,6 +955,8 @@ MediaStationGo 不会把 `ffmpeg` 或 `ffprobe` 作为常驻守护进程启动
|
|||||||
|
|
||||||
播放停止、转码任务取消或服务退出时,后台会结束对应转码任务。空闲状态下如果没有扫描、探测或转码,`ffmpeg/ffprobe` 不应持续占用 CPU。
|
播放停止、转码任务取消或服务退出时,后台会结束对应转码任务。空闲状态下如果没有扫描、探测或转码,`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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 刮削与元数据策略
|
## 🔍 刮削与元数据策略
|
||||||
|
|||||||
+13
-11
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
|
|||||||
|
|
||||||
| Platform | Package example |
|
| Platform | Package example |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Linux x86_64 | `MediaStationGo-v0.0.15-linux-amd64.tar.gz` |
|
| Linux x86_64 | `MediaStationGo-v0.0.16-linux-amd64.tar.gz` |
|
||||||
| Linux ARM64 | `MediaStationGo-v0.0.15-linux-arm64.tar.gz` |
|
| Linux ARM64 | `MediaStationGo-v0.0.16-linux-arm64.tar.gz` |
|
||||||
| Windows x86_64 | `MediaStationGo-v0.0.15-windows-amd64.zip` |
|
| Windows x86_64 | `MediaStationGo-v0.0.16-windows-amd64.zip` |
|
||||||
| macOS Intel | `MediaStationGo-v0.0.15-darwin-amd64.tar.gz` |
|
| macOS Intel | `MediaStationGo-v0.0.16-darwin-amd64.tar.gz` |
|
||||||
| macOS Apple Silicon | `MediaStationGo-v0.0.15-darwin-arm64.tar.gz` |
|
| macOS Apple Silicon | `MediaStationGo-v0.0.16-darwin-arm64.tar.gz` |
|
||||||
|
|
||||||
Linux example:
|
Linux example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf MediaStationGo-v0.0.15-linux-amd64.tar.gz
|
tar -xzf MediaStationGo-v0.0.16-linux-amd64.tar.gz
|
||||||
cd MediaStationGo-v0.0.15-linux-amd64
|
cd MediaStationGo-v0.0.16-linux-amd64
|
||||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows example:
|
Windows example:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Expand-Archive .\MediaStationGo-v0.0.15-windows-amd64.zip
|
Expand-Archive .\MediaStationGo-v0.0.16-windows-amd64.zip
|
||||||
cd .\MediaStationGo-v0.0.15-windows-amd64
|
cd .\MediaStationGo-v0.0.16-windows-amd64
|
||||||
$env:MEDIASTATION_APP_PORT = "18080"
|
$env:MEDIASTATION_APP_PORT = "18080"
|
||||||
.\mediastation-go.exe
|
.\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.
|
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
|
## 🔍 Metadata Strategy
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ transcoder:
|
|||||||
# nvenc h264_nvenc (NVIDIA GPU, requires --gpus all)
|
# nvenc h264_nvenc (NVIDIA GPU, requires --gpus all)
|
||||||
# qsv h264_qsv (Intel iGPU, requires /dev/dri)
|
# qsv h264_qsv (Intel iGPU, requires /dev/dri)
|
||||||
# vaapi h264_vaapi (Intel/AMD VAAPI, 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: ""
|
encoder: ""
|
||||||
preset: veryfast
|
preset: veryfast
|
||||||
video_bitrate: 1500k
|
video_bitrate: 1500k
|
||||||
@@ -77,6 +79,10 @@ transcoder:
|
|||||||
buf_size: 3000k
|
buf_size: 3000k
|
||||||
max_height: 720
|
max_height: 720
|
||||||
segment_seconds: 4
|
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:
|
ai:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
+12
-3
@@ -17,7 +17,7 @@
|
|||||||
#
|
#
|
||||||
# 镜像版本:
|
# 镜像版本:
|
||||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15
|
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.16
|
||||||
#
|
#
|
||||||
# 路径映射总览:
|
# 路径映射总览:
|
||||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||||
@@ -128,8 +128,15 @@ services:
|
|||||||
MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info}
|
MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info}
|
||||||
|
|
||||||
# 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。
|
# 转码配置。留空表示自动/软件转码;硬件加速见下方 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_ENCODER: ${MEDIASTATION_TRANSCODER_ENCODER:-}
|
||||||
MEDIASTATION_TRANSCODER_MAX_HEIGHT: ${MEDIASTATION_TRANSCODER_MAX_HEIGHT:-1080}
|
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:-}
|
MEDIASTATION_APP_CORS_ORIGINS: ${MEDIASTATION_APP_CORS_ORIGINS:-}
|
||||||
@@ -141,7 +148,8 @@ services:
|
|||||||
|
|
||||||
# Intel QSV / VAAPI 硬件加速示例:
|
# Intel QSV / VAAPI 硬件加速示例:
|
||||||
# 1. Linux/NAS 宿主机存在 /dev/dri。
|
# 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 注释。
|
# 3. 取消下方 devices/group_add 注释。
|
||||||
# devices:
|
# devices:
|
||||||
# - /dev/dri:/dev/dri
|
# - /dev/dri:/dev/dri
|
||||||
@@ -150,7 +158,8 @@ services:
|
|||||||
|
|
||||||
# NVIDIA NVENC 硬件加速示例:
|
# NVIDIA NVENC 硬件加速示例:
|
||||||
# 1. 宿主机安装 NVIDIA Container Toolkit。
|
# 1. 宿主机安装 NVIDIA Container Toolkit。
|
||||||
# 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_ENCODER=nvenc。
|
# 2. 在 .env 中设置 MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=true
|
||||||
|
# 和 MEDIASTATION_TRANSCODER_ENCODER=nvenc。
|
||||||
# 3. 取消下方 gpus 注释。
|
# 3. 取消下方 gpus 注释。
|
||||||
# gpus: all
|
# gpus: all
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,19 @@ type ApiConfigConfig struct {
|
|||||||
|
|
||||||
// TranscoderConfig 控制 HLS / ffmpeg 后端。
|
// TranscoderConfig 控制 HLS / ffmpeg 后端。
|
||||||
type TranscoderConfig struct {
|
type TranscoderConfig struct {
|
||||||
Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi
|
Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi
|
||||||
Preset string `mapstructure:"preset"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
VideoBitrate string `mapstructure:"video_bitrate"`
|
HardwareAccel bool `mapstructure:"hardware_accel"`
|
||||||
MaxRate string `mapstructure:"max_rate"`
|
Preset string `mapstructure:"preset"`
|
||||||
BufSize string `mapstructure:"buf_size"`
|
VideoBitrate string `mapstructure:"video_bitrate"`
|
||||||
MaxHeight int `mapstructure:"max_height"`
|
MaxRate string `mapstructure:"max_rate"`
|
||||||
SegmentSeconds int `mapstructure:"segment_seconds"`
|
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 保存运行时应用参数。
|
// AppConfig 保存运行时应用参数。
|
||||||
@@ -259,12 +265,18 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("organizer.categories.uncategorized_tv", "未分类")
|
v.SetDefault("organizer.categories.uncategorized_tv", "未分类")
|
||||||
|
|
||||||
v.SetDefault("transcoder.encoder", "")
|
v.SetDefault("transcoder.encoder", "")
|
||||||
|
v.SetDefault("transcoder.enabled", true)
|
||||||
|
v.SetDefault("transcoder.hardware_accel", false)
|
||||||
v.SetDefault("transcoder.preset", "veryfast")
|
v.SetDefault("transcoder.preset", "veryfast")
|
||||||
v.SetDefault("transcoder.video_bitrate", "1500k")
|
v.SetDefault("transcoder.video_bitrate", "1500k")
|
||||||
v.SetDefault("transcoder.max_rate", "1800k")
|
v.SetDefault("transcoder.max_rate", "1800k")
|
||||||
v.SetDefault("transcoder.buf_size", "3000k")
|
v.SetDefault("transcoder.buf_size", "3000k")
|
||||||
v.SetDefault("transcoder.max_height", 720)
|
v.SetDefault("transcoder.max_height", 720)
|
||||||
v.SetDefault("transcoder.segment_seconds", 4)
|
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 默认设置
|
// API Config 默认设置
|
||||||
v.SetDefault("api_config.auto_encrypt", true)
|
v.SetDefault("api_config.auto_encrypt", true)
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
|
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)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -64,8 +64,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
"items": []gin.H{
|
"items": []gin.H{
|
||||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
{"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.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": "ffmpeg.path", "type": "text", "label": "FFmpeg 路径"},
|
||||||
{"key": "ffprobe.path", "type": "text", "label": "FFprobe 路径"},
|
{"key": "ffprobe.path", "type": "text", "label": "FFprobe 路径"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
|||||||
cfg.App.FFmpegPath = value
|
cfg.App.FFmpegPath = value
|
||||||
case "ffprobe.path", "app.ffprobe_path":
|
case "ffprobe.path", "app.ffprobe_path":
|
||||||
cfg.App.FFprobePath = value
|
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":
|
case "transcode.hw_accel", "transcoder.encoder":
|
||||||
switch value {
|
switch value {
|
||||||
case "", "auto", "none", "software":
|
case "", "auto", "none", "software":
|
||||||
@@ -48,6 +52,20 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
|||||||
if n, err := strconv.Atoi(value); err == nil {
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
cfg.Transcoder.MaxHeight = n
|
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":
|
case "transcode.video_bitrate", "transcoder.video_bitrate":
|
||||||
cfg.Transcoder.VideoBitrate = value
|
cfg.Transcoder.VideoBitrate = value
|
||||||
case "license.server_url":
|
case "license.server_url":
|
||||||
@@ -56,3 +74,14 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
|||||||
cfg.License.HMACSecret = value
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
|||||||
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
s.transcoder.TouchJob(mediaID)
|
||||||
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
|
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
|
||||||
return errors.New("hls playlist not ready")
|
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.
|
// 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 {
|
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
|
// Only allow segments that look like seg_NNNNN.ts so we cannot be tricked
|
||||||
// into reading arbitrary files via path traversal.
|
// into reading arbitrary files via path traversal.
|
||||||
if !strings.HasPrefix(segment, "seg_") || !strings.HasSuffix(segment, ".ts") {
|
if !strings.HasPrefix(segment, "seg_") || !strings.HasSuffix(segment, ".ts") {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -56,10 +57,19 @@ type hlsJob struct {
|
|||||||
outputDir string
|
outputDir string
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
|
lastAccess time.Time
|
||||||
playlistOK bool
|
playlistOK bool
|
||||||
encoder string
|
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.
|
// NewTranscoderService is the constructor.
|
||||||
func NewTranscoderService(cfg *config.Config, log *zap.Logger, repo *repository.Container, hub *Hub) *TranscoderService {
|
func NewTranscoderService(cfg *config.Config, log *zap.Logger, repo *repository.Container, hub *Hub) *TranscoderService {
|
||||||
return &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
|
// non-blocking: it returns the playlist path immediately. The caller is
|
||||||
// expected to poll until WaitReady reports true.
|
// expected to poll until WaitReady reports true.
|
||||||
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
|
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)
|
m, err := t.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -101,9 +114,14 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
|||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if _, ok := t.jobs[mediaID]; ok {
|
if _, ok := t.jobs[mediaID]; ok {
|
||||||
|
t.touchJobLocked(mediaID)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return t.PlaylistPath(mediaID), nil
|
return t.PlaylistPath(mediaID), nil
|
||||||
}
|
}
|
||||||
|
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return "", ErrTranscodeBusy
|
||||||
|
}
|
||||||
|
|
||||||
outDir := t.HLSDir(mediaID)
|
outDir := t.HLSDir(mediaID)
|
||||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
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())
|
jobCtx, cancel := context.WithCancel(context.Background())
|
||||||
job := &hlsJob{
|
job := &hlsJob{
|
||||||
mediaID: mediaID,
|
mediaID: mediaID,
|
||||||
outputDir: outDir,
|
outputDir: outDir,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
encoder: t.cfg.Transcoder.Encoder,
|
lastAccess: time.Now(),
|
||||||
|
encoder: t.effectiveEncoder(),
|
||||||
}
|
}
|
||||||
t.jobs[mediaID] = job
|
t.jobs[mediaID] = job
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
go t.monitorIdle(jobCtx, job)
|
||||||
go t.runFFmpeg(jobCtx, job, m.Path)
|
go t.runFFmpeg(jobCtx, job, m.Path)
|
||||||
return t.PlaylistPath(mediaID), nil
|
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).
|
// StopAll terminates every running transcode (called on graceful shutdown).
|
||||||
func (t *TranscoderService) StopAll() {
|
func (t *TranscoderService) StopAll() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
@@ -194,6 +229,51 @@ func (t *TranscoderService) Active() []ActiveJob {
|
|||||||
return out
|
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) {
|
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
|
||||||
bin, err := t.resolveFFmpegPath()
|
bin, err := t.resolveFFmpegPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,7 +329,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
|||||||
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, bin := range executableCandidates(strings.TrimSpace(t.cfg.App.FFmpegPath), "ffmpeg") {
|
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
|
lastErr = err
|
||||||
continue
|
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")
|
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 {
|
func validateFFmpegForTranscode(ctx context.Context, bin, encoder string) error {
|
||||||
required := requiredVideoEncoder(encoder)
|
required := requiredVideoEncoder(encoder)
|
||||||
out, err := commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-encoders")
|
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
|
// encoder. The function is package-level so the unit test can pin its
|
||||||
// behaviour without spawning a real ffmpeg process.
|
// behaviour without spawning a real ffmpeg process.
|
||||||
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
|
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
|
bitrate := cfg.Transcoder.VideoBitrate
|
||||||
if bitrate == "" {
|
if bitrate == "" {
|
||||||
bitrate = "1500k"
|
bitrate = "1500k"
|
||||||
@@ -373,11 +472,17 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
|
|||||||
vpreset = preset
|
vpreset = preset
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"-y", "-fflags", "+genpts"}
|
args := []string{"-y", "-hide_banner", "-nostdin", "-fflags", "+genpts"}
|
||||||
for _, p := range splitNonEmptyArgs(pre) {
|
for _, p := range splitNonEmptyArgs(pre) {
|
||||||
args = append(args, p)
|
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)
|
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 != "" {
|
if vpreset != "" {
|
||||||
args = append(args, "-preset", vpreset)
|
args = append(args, "-preset", vpreset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ func TestBuildFFmpegArgs(t *testing.T) {
|
|||||||
base := &config.Config{}
|
base := &config.Config{}
|
||||||
base.Transcoder.MaxHeight = 720
|
base.Transcoder.MaxHeight = 720
|
||||||
base.Transcoder.SegmentSeconds = 4
|
base.Transcoder.SegmentSeconds = 4
|
||||||
|
base.Transcoder.Realtime = true
|
||||||
|
base.Transcoder.Threads = 2
|
||||||
base.App.VAAPIDevice = "/dev/dri/renderD128"
|
base.App.VAAPIDevice = "/dev/dri/renderD128"
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -20,7 +22,7 @@ func TestBuildFFmpegArgs(t *testing.T) {
|
|||||||
expectInArgs []string
|
expectInArgs []string
|
||||||
expectNotPresetIfBlank bool
|
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},
|
{"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},
|
{"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},
|
{"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) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
cfg := *base
|
cfg := *base
|
||||||
cfg.Transcoder.Encoder = tc.encoder
|
cfg.Transcoder.Encoder = tc.encoder
|
||||||
|
cfg.Transcoder.HardwareAccel = tc.encoder != ""
|
||||||
args := buildFFmpegArgs(&cfg, "/x.mkv", "/o/x.m3u8", "/o/seg_%05d.ts")
|
args := buildFFmpegArgs(&cfg, "/x.mkv", "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||||
joined := strings.Join(args, " ")
|
joined := strings.Join(args, " ")
|
||||||
for _, frag := range tc.expectInArgs {
|
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) {
|
func TestRequiredVideoEncoder(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"": "libx264",
|
"": "libx264",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
|||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
import { hlsURL, streamURL } from '../api/client'
|
import { api, hlsURL, streamURL } from '../api/client'
|
||||||
import { playbackAPI } from '../api/playback'
|
import { playbackAPI } from '../api/playback'
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
@@ -99,7 +99,7 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
return teardownHls
|
return () => teardownHls(media.id, mode === 'hls')
|
||||||
}, [hlsUnavailable, media, mode, params, setParams])
|
}, [hlsUnavailable, media, mode, params, setParams])
|
||||||
|
|
||||||
// Persist resume position every 10 seconds while playing.
|
// Persist resume position every 10 seconds while playing.
|
||||||
@@ -136,11 +136,14 @@ export function PlayerPage() {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [id, media?.id, navigate])
|
}, [id, media?.id, navigate])
|
||||||
|
|
||||||
const teardownHls = () => {
|
const teardownHls = (mediaId?: string, stopServer = false) => {
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
hlsRef.current.destroy()
|
hlsRef.current.destroy()
|
||||||
hlsRef.current = null
|
hlsRef.current = null
|
||||||
}
|
}
|
||||||
|
if (stopServer && mediaId) {
|
||||||
|
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SettingDef {
|
|||||||
label: string
|
label: string
|
||||||
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
|
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
|
||||||
hint?: string
|
hint?: string
|
||||||
|
defaultValue?: string
|
||||||
options?: { value: string; label: string }[]
|
options?: { value: string; label: string }[]
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
@@ -51,25 +52,55 @@ const GROUPS: SettingGroup[] = [
|
|||||||
label: '启用转码',
|
label: '启用转码',
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
hint: '关闭后所有视频直连播放',
|
hint: '关闭后所有视频直连播放',
|
||||||
|
defaultValue: 'true',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'transcode.hw_accel',
|
key: 'transcode.hw_accel',
|
||||||
label: '硬件加速',
|
label: '硬件编码器',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
|
hint: '只有开启下方「启用硬件加速」后才会使用;未开启时强制软件转码',
|
||||||
|
defaultValue: 'none',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'auto', label: '自动检测' },
|
|
||||||
{ value: 'none', label: '软件转码' },
|
{ value: 'none', label: '软件转码' },
|
||||||
{ value: 'nvenc', label: 'NVIDIA NVENC' },
|
{ value: 'nvenc', label: 'NVIDIA NVENC' },
|
||||||
{ value: 'qsv', label: 'Intel QSV' },
|
{ value: 'qsv', label: 'Intel QSV' },
|
||||||
{ value: 'vaapi', label: 'VAAPI (Linux)' },
|
{ 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',
|
key: 'transcode.max_jobs',
|
||||||
label: '最大并发转码任务',
|
label: '最大并发转码任务',
|
||||||
type: 'number',
|
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',
|
key: 'ffmpeg.path',
|
||||||
@@ -299,7 +330,7 @@ export function SettingsPage() {
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
key={it.key}
|
key={it.key}
|
||||||
def={it}
|
def={it}
|
||||||
value={values[it.key] ?? ''}
|
value={values[it.key] ?? it.defaultValue ?? ''}
|
||||||
onChange={(v) => onChange(it.key, v)}
|
onChange={(v) => onChange(it.key, v)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -369,6 +400,7 @@ function SettingRow({
|
|||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const toggleOn = value === 'true' || value === '1' || value === 'on'
|
||||||
return (
|
return (
|
||||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||||
<label className="text-sm text-ink-100">
|
<label className="text-sm text-ink-100">
|
||||||
@@ -418,10 +450,10 @@ function SettingRow({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4 accent-primary-400"
|
className="h-4 w-4 accent-primary-400"
|
||||||
checked={value === 'true' || value === '1' || value === 'on'}
|
checked={toggleOn}
|
||||||
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-ink-100">{value === 'true' ? '已启用' : '已关闭'}</span>
|
<span className="text-sm text-ink-100">{toggleOn ? '已启用' : '已关闭'}</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user