mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 21:13:10 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80b0980eac | |||
| 75632de2e6 | |||
| b85f82c76d | |||
| 3ce3dc4109 | |||
| 6a46f0e809 | |||
| 609d0a7b84 | |||
| 9a28f7c09c | |||
| 6646ffe891 | |||
| 20d9d39f83 | |||
| ccb74025ea | |||
| 94002a2118 | |||
| e750ff502a | |||
| 97e885fee6 | |||
| f891d3df1f | |||
| 6e9878fe61 | |||
| 08d9e2eb70 | |||
| 0386f87110 | |||
| 4c9e63611f | |||
| b00e694f68 | |||
| ae6f05af11 | |||
| 8dbcd0fd88 | |||
| 2182ccff96 | |||
| b5f72ecdcc | |||
| 5a27d5fa6d | |||
| ce6bff2ad8 | |||
| cc1fec59ac | |||
| 29f0636833 | |||
| 8a72c78075 | |||
| 3527cab26b | |||
| dc92fccf52 | |||
| e33ff8d9f7 | |||
| 7571afcd41 | |||
| 85c7f85b18 | |||
| 9775971eb7 | |||
| 9096eb60c2 | |||
| 51c703c0a8 | |||
| 0e33727675 | |||
| 879870ef63 | |||
| bd84607537 | |||
| 0f340111da | |||
| e776d2ca43 |
@@ -0,0 +1,153 @@
|
||||
name: Beta Media Release builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev-media"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
name: Beta Media Release Changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create or update ref
|
||||
id: create-or-update-ref
|
||||
uses: ovsds/create-or-update-ref-action@v1
|
||||
with:
|
||||
ref: tags/beta-media
|
||||
sha: ${{ github.sha }}
|
||||
|
||||
- name: Delete beta-media tag
|
||||
run: git tag -d beta-media
|
||||
continue-on-error: true
|
||||
|
||||
- name: changelog
|
||||
id: changelog
|
||||
run: |
|
||||
git tag -l
|
||||
npx changelogithub --output CHANGELOG.md
|
||||
|
||||
- name: Upload assets to beta-media release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: CHANGELOG.md
|
||||
files: CHANGELOG.md
|
||||
prerelease: true
|
||||
tag_name: beta-media
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: beta-media changelog
|
||||
path: ${{ github.workspace }}/CHANGELOG.md
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs:
|
||||
- changelog
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch
|
||||
hash: "md5"
|
||||
- target: "linux-!(arm*)-musl*" #musl-not-arm
|
||||
hash: "md5-linux-musl"
|
||||
- target: "linux-arm*-musl*" #musl-arm
|
||||
hash: "md5-linux-musl-arm"
|
||||
- target: "windows-arm64" #win-arm64
|
||||
hash: "md5-windows-arm64"
|
||||
- target: "windows7-*" #win7
|
||||
hash: "md5-windows7"
|
||||
- target: "android-*" #android
|
||||
hash: "md5-android"
|
||||
- target: "freebsd-*" #freebsd
|
||||
hash: "md5-freebsd"
|
||||
|
||||
name: Beta Media Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.0"
|
||||
|
||||
- name: Setup web
|
||||
run: |
|
||||
frontendRepo="${FRONTEND_REPO:-OpenListTeam/OpenList-Frontend}"
|
||||
release_json=$(curl -fsSL --max-time 10 \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$frontendRepo/releases/tags/beta-media")
|
||||
tar_url=$(echo "$release_json" | jq -r '.assets[].browser_download_url' | grep "openlist-frontend-dist" | grep -v "lite" | grep "\.tar\.gz$")
|
||||
echo "Downloading frontend from: $tar_url"
|
||||
curl -fsSL "$tar_url" -o dist.tar.gz
|
||||
rm -rf public/dist && mkdir -p public/dist
|
||||
tar -zxvf dist.tar.gz -C public/dist
|
||||
rm -rf dist.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
|
||||
|
||||
- name: Build
|
||||
uses: OpenListTeam/cgo-actions@v1.2.2
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
musl-target-format: $os-$musl-$arch
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
out-dir: build
|
||||
output: openlist-$target$ext
|
||||
musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
|
||||
x-flags: |
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@openlist.team>
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag
|
||||
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
|
||||
|
||||
- name: Compress
|
||||
run: |
|
||||
bash build.sh zip ${{ matrix.hash }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload assets to beta-media release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/compress/*
|
||||
prerelease: true
|
||||
tag_name: beta-media
|
||||
|
||||
- name: Clean illegal characters from matrix.target
|
||||
id: clean_target_name
|
||||
run: |
|
||||
ILLEGAL_CHARS_REGEX='[":<>|*?\\/\r\n]'
|
||||
CLEANED_TARGET=$(echo "${{ matrix.target }}" | sed -E "s/$ILLEGAL_CHARS_REGEX//g")
|
||||
echo "Original target: ${{ matrix.target }}"
|
||||
echo "Cleaned target: $CLEANED_TARGET"
|
||||
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload assets to github artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: beta-media builds for ${{ env.cleaned_target }}
|
||||
path: ${{ github.workspace }}/build/compress/*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
# OpenList 转码协议 v1(自研)
|
||||
|
||||
> 用于 OpenList Master 与远程 FFmpeg 转码 Worker 之间的通信。
|
||||
> 协议本身、所有依赖、推荐编码 (H.264/AV1) 均为开源免费,可商用。
|
||||
|
||||
## 总览
|
||||
|
||||
```
|
||||
Player ──► Master(/api/transcode/play) ──► Scheduler.Submit
|
||||
Worker ──► Master(/api/transcode/worker/register) # 启动一次
|
||||
Worker ──► Master(/api/transcode/worker/heartbeat) # 周期 10s
|
||||
Worker ──► Master(/api/transcode/worker/claim) # 长轮询拉任务
|
||||
Worker ──► Master(/api/transcode/worker/segment) # 流式回推切片
|
||||
Worker ──► Master(/api/transcode/worker/job/finish) # 上报完成
|
||||
Player ──► Master(/tc/{job}/{token}/master.m3u8) # 拉取播放列表
|
||||
Player ──► Master(/tc/{job}/{token}/{profile}/seg-N.ts) # 拉取切片
|
||||
```
|
||||
|
||||
## 通用约定
|
||||
- 所有请求体均为 JSON。
|
||||
- Worker 与 Master 间通过两类凭据鉴权:
|
||||
- **shared_secret**(管理端 `transcode_worker_secret` 设置项):用于 register / heartbeat / claim
|
||||
- **callback_token**(Master 在 job 中下发,一次性):用于 segment / job/finish
|
||||
- Header 形式:`Authorization: Bearer <token>`
|
||||
- 返回包统一使用 OpenList 通用响应:`{"code":200,"message":"success","data":{...}}`
|
||||
|
||||
## 端点详细
|
||||
|
||||
### POST /api/transcode/worker/register
|
||||
> 鉴权:Bearer = shared_secret
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"name": "gpu-node-01",
|
||||
"version": "1.0.0",
|
||||
"capacity": 4,
|
||||
"hwaccel": ["nvenc"],
|
||||
"codecs_decode": ["h264","hevc","av1","vp9"],
|
||||
"codecs_encode": ["h264","hevc"],
|
||||
"max_resolution": "3840x2160",
|
||||
"tags": ["linux","amd64"]
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code":200,"message":"success",
|
||||
"data": {
|
||||
"worker_id":"wk_xxx",
|
||||
"heartbeat_interval":10,
|
||||
"claim_strategy":"pull",
|
||||
"protocol_version":"v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/transcode/worker/heartbeat
|
||||
> 鉴权:Bearer = shared_secret
|
||||
> 间隔:register 返回的 `heartbeat_interval`;服务端 30s 未收到则剔除 worker
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "worker_id":"wk_xxx", "load":0.4, "running":["job_a"], "free_slots":3 }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "code":200,"message":"success","data":{"ok":true,"kick":false,"cancel":["job_x"]}}
|
||||
```
|
||||
- `kick=true`:服务端要求 Worker 主动退出
|
||||
- `cancel`:需要 Worker 立刻终止的 job_ids(异步取消通道)
|
||||
|
||||
### POST /api/transcode/worker/claim
|
||||
> 鉴权:Bearer = shared_secret
|
||||
> 长轮询:当无任务时,服务端最多挂起 `wait` 秒(最大 30)
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "worker_id":"wk_xxx", "slots":1, "wait":20 }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "code":200,"message":"success","data":{"jobs":[{
|
||||
"id":"job_xxx",
|
||||
"path":"/movies/x.mkv",
|
||||
"source_url":"https://example.com/d/movies/x.mkv?sign=...",
|
||||
"profiles":[{
|
||||
"name":"1080p","video_codec":"h264","video_bitrate":"4000k",
|
||||
"scale":"1920:-2","audio_codec":"aac","audio_bitrate":"160k",
|
||||
"hwaccel":"nvenc"
|
||||
}],
|
||||
"output":{"format":"hls","segment_duration":6},
|
||||
"callback_token":"tk_xxx",
|
||||
"deadline":1731601234
|
||||
}]}}
|
||||
```
|
||||
|
||||
### PUT /api/transcode/worker/segment
|
||||
> 鉴权:Bearer = callback_token
|
||||
> Query 参数:
|
||||
> - `job` 任务 ID
|
||||
> - `profile` profile 名(如 `1080p`)
|
||||
> - `seq` 切片序号(从 0 开始)
|
||||
> - `duration` 该切片秒数
|
||||
> - `final` 是否为最后一片(true/false)
|
||||
>
|
||||
> Body:原始 MPEG-TS 二进制(`Content-Type: video/mp2t`)
|
||||
|
||||
响应:`204 No Content`
|
||||
|
||||
### POST /api/transcode/worker/job/finish
|
||||
> 鉴权:Bearer = callback_token
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"job_id":"job_xxx",
|
||||
"status":"finished",
|
||||
"error":"",
|
||||
"stats":{"elapsed":312.4,"fps":120,"speed":2.1,"bytes_out":890123456}
|
||||
}
|
||||
```
|
||||
status 取值:`finished` / `failed` / `cancelled`
|
||||
|
||||
## 播放端
|
||||
|
||||
### POST /api/fs/transcode/play
|
||||
> 由前端播放器调用,用户登录态可见
|
||||
> 请求:`{"path":"/movies/x.mkv"}`
|
||||
>
|
||||
> 响应(无需转码):
|
||||
> `{"transcode":false,"reason":"size below threshold"}`
|
||||
>
|
||||
> 响应(已下发转码任务):
|
||||
> `{"transcode":true,"job_id":"job_xx","master_url":"https://.../tc/.../master.m3u8","profile":"1080p"}`
|
||||
|
||||
### GET /tc/:job/:token/master.m3u8
|
||||
返回多档位主 m3u8。
|
||||
|
||||
### GET /tc/:job/:token/:profile/playlist.m3u8
|
||||
阻塞等待首切片就绪(最多 30s)后返回 HLS 播放列表,未结束时不带 `#EXT-X-ENDLIST`。
|
||||
|
||||
### GET /tc/:job/:token/:profile/seg-N.ts
|
||||
返回 N 号切片;如果尚未生成,最多等待 60s。
|
||||
|
||||
## 启动远程 Worker
|
||||
|
||||
```bash
|
||||
# 用 Master 配置中的 transcode_worker_secret 作为密钥
|
||||
go build -o openlist-worker ./cmd/worker
|
||||
|
||||
./openlist-worker \
|
||||
-master http://openlist.example.com:5244 \
|
||||
-secret <transcode_worker_secret> \
|
||||
-capacity 2 \
|
||||
-hwaccel nvenc \
|
||||
-ffmpeg /usr/bin/ffmpeg \
|
||||
-workdir /tmp/openlist-worker
|
||||
```
|
||||
|
||||
## License 说明
|
||||
- 协议:自研,可自由使用
|
||||
- FFmpeg:使用系统包提供的 LGPL 版本即可(含 libx264/libx265,libx264 是 GPL,建议在 Worker 镜像中遵循 GPL 公开/动态链接合规)
|
||||
- 推荐编码:H.264 互联网分发免授权 / AV1 永久免版税
|
||||
@@ -65,6 +65,7 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/quic-go/quic-go v0.54.1
|
||||
github.com/rclone/rclone v1.70.3
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||
github.com/shirou/gopsutil/v4 v4.25.5
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/afero v1.15.0
|
||||
@@ -85,8 +86,8 @@ require (
|
||||
gopkg.in/ldap.v3 v3.1.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/gorm v1.25.11
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -120,6 +121,7 @@ require (
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/lanrat/extsort v1.0.2 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.0 // indirect
|
||||
github.com/minio/xxml v0.0.3 // indirect
|
||||
@@ -250,7 +252,6 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
|
||||
@@ -27,8 +27,6 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y
|
||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE=
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc=
|
||||
github.com/OpenListTeam/115-sdk-go v0.2.3 h1:nDNz0GxgliW+nT2Ds486k/rp/GgJj7Ngznc98ZBUwZo=
|
||||
github.com/OpenListTeam/115-sdk-go v0.2.3/go.mod h1:cfvitk2lwe6036iNi2h+iNxwxWDifKZsSvNtrur5BqU=
|
||||
github.com/OpenListTeam/115-sdk-go v0.2.4 h1:dVoEz3Pm996n/ZCuRckQvz36w938uNHjPrS7E/SVpBM=
|
||||
github.com/OpenListTeam/115-sdk-go v0.2.4/go.mod h1:cfvitk2lwe6036iNi2h+iNxwxWDifKZsSvNtrur5BqU=
|
||||
github.com/OpenListTeam/go-cache v0.1.0 h1:eV2+FCP+rt+E4OCJqLUW7wGccWZNJMV0NNkh+uChbAI=
|
||||
@@ -80,19 +78,13 @@ github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
|
||||
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
|
||||
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
@@ -601,6 +593,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
||||
@@ -861,11 +855,11 @@ gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0=
|
||||
lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
|
||||
@@ -249,6 +249,79 @@ func InitialSettings() []model.SettingItem {
|
||||
{Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
{Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
||||
|
||||
// media settings
|
||||
{Key: conf.MediaTMDBKey, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaTMDBAPIURL, Value: "api.themoviedb.org", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaDiscogsToken, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaDiscogsAPIURL, Value: "api.discogs.com", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaStoreThumbnail, Value: "false", Type: conf.TypeBool, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaThumbnailMode, Value: "base64", Type: conf.TypeSelect, Options: "base64,local", Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaThumbnailPath, Value: "/imgs", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
{Key: conf.MediaScrapeConcurrency, Value: "5", Type: conf.TypeNumber, Group: model.MEDIA, Flag: model.PRIVATE},
|
||||
|
||||
// transcode settings (FFmpeg 云端/本地转码) - 默认全部关闭
|
||||
{Key: conf.TranscodeEnabled, Value: "false", Type: conf.TypeBool, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `开启后,超过阈值的媒体文件将通过 FFmpeg 转码后再播放;默认关闭`},
|
||||
{Key: conf.TranscodeRunMode, Value: "local", Type: conf.TypeSelect, Options: "local,remote,hybrid", Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `local=仅使用本机内置 worker;remote=只使用远程 Worker 节点;hybrid=本地优先,超载后派发到远程`},
|
||||
{Key: conf.TranscodeMinSizeGB, Value: "5", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `文件大于该 GB 数才走转码(小于则直链播放),0=任意大小都转码`},
|
||||
{Key: conf.TranscodeMinBitrateMbps, Value: "20", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `视频码率超过该 Mbps 才转码,0=不限制`},
|
||||
{Key: conf.TranscodeSourceCodecs, Value: "hevc,h265,av1,vvc,vp9", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `仅对这些源视频编码进行转码(逗号分隔)。常见高码率/兼容性差的编码:hevc,av1,vvc,vp9`},
|
||||
{Key: conf.TranscodeSourceExtensions, Value: "mkv,ts,m2ts,mov,avi,wmv,flv,rmvb,webm", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `仅对这些后缀进行转码(逗号分隔,不带点),mp4 默认不转码可直接播放`},
|
||||
{Key: conf.TranscodeOutputFormat, Value: "hls", Type: conf.TypeSelect, Options: "hls,dash,mp4", Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `输出封装格式,HLS 兼容性最好`},
|
||||
{Key: conf.TranscodeOutputCodec, Value: "h264", Type: conf.TypeSelect, Options: "h264,hevc,av1", Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `重新编码后的视频编码,推荐 h264(最广兼容、互联网分发免授权费)`},
|
||||
{Key: conf.TranscodeOutputBitrate, Value: "4000k", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `输出视频码率,例如 4000k / 6M。建议 1080p:4000k、720p:2500k`},
|
||||
{Key: conf.TranscodeOutputAudioCodec, Value: "aac", Type: conf.TypeSelect, Options: "aac,mp3,opus,copy", Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `输出音频编码,aac 兼容性最好;copy=直接复制源音频流`},
|
||||
{Key: conf.TranscodeOutputAudioBitrate, Value: "160k", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `输出音频码率`},
|
||||
{Key: conf.TranscodeOutputResolution, Value: "1920x1080", Type: conf.TypeSelect, Options: "source,3840x2160,2560x1440,1920x1080,1280x720,854x480", Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `输出分辨率上限,超过该分辨率会下采样;source=保持源分辨率`},
|
||||
{Key: conf.TranscodeSegmentDuration, Value: "6", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `HLS/DASH 切片时长(秒),越小首帧越快但请求数变多,推荐 4-10`},
|
||||
{Key: conf.TranscodeHWAccel, Value: "none", Type: conf.TypeSelect,
|
||||
Options: "none,auto,nvenc,qsv,vaapi,amf,videotoolbox",
|
||||
Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `GPU 硬件加速:
|
||||
none = 纯 CPU(libx264)
|
||||
auto = 自动探测可用加速器
|
||||
nvenc = NVIDIA GPU(GeForce/Tesla/Quadro/RTX,需 NVIDIA 驱动)
|
||||
qsv = Intel 集显/独显 QuickSync(免费,功耗低)
|
||||
vaapi = Linux 通用 VA-API(支持 Intel/AMD)
|
||||
amf = AMD GPU(Windows AMF)
|
||||
videotoolbox = macOS 硬件加速`},
|
||||
{Key: conf.TranscodeFFmpegPath, Value: "ffmpeg", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `FFmpeg 可执行路径,留空则使用 PATH 中的 ffmpeg`},
|
||||
{Key: conf.TranscodeFFprobePath, Value: "ffprobe", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `FFprobe 可执行路径`},
|
||||
{Key: conf.TranscodeWorkerSecret, Value: "", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `远程 Worker 注册时使用的共享密钥,留空则禁用远程 Worker 注册`},
|
||||
{Key: conf.TranscodeCachePath, Value: "data/transcode_cache", Type: conf.TypeString, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `转码切片缓存目录`},
|
||||
{Key: conf.TranscodeCacheMaxGB, Value: "20", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `切片缓存最大容量(GB),超过后按 LRU 清理`},
|
||||
{Key: conf.TranscodeJobTimeoutMin, Value: "120", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `单个转码任务超时分钟数,超时自动失败`},
|
||||
{Key: conf.TranscodeLocalConcurrency, Value: "1", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `本机内置 worker 同时执行的转码任务数(local/hybrid 模式生效)`},
|
||||
{Key: conf.TranscodeIdleTimeoutSec, Value: "90", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `播放端无请求多少秒后自动停止转码(默认90秒,0=禁用)`},
|
||||
{Key: conf.TranscodeChunkDurationSec, Value: "60", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `每个 chunk 时长(秒),越大每段缓冲越长但 ffmpeg 进程切换越少(默认60秒)`},
|
||||
{Key: conf.TranscodeMaxChunkConcurrency, Value: "2", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `单个视频任务最多同时运行的 chunk 数(默认2,越大并发越高但 CPU/GPU 占用越多)`},
|
||||
{Key: conf.TranscodeChunkIdleSec, Value: "60", Type: conf.TypeNumber, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `chunk 多久没被访问就 kill ffmpeg 释放资源(默认60秒)`},
|
||||
{Key: conf.TranscodeChunkPrefetch, Value: "true", Type: conf.TypeBool, Group: model.TRANSCODE, Flag: model.PRIVATE,
|
||||
Help: `当前 chunk 转码进度过半时,是否预启动下一个 chunk(默认开启,可让顺序播放无缝衔接)`},
|
||||
}
|
||||
additionalSettingItems := tool.Tools.Items()
|
||||
// 固定顺序
|
||||
|
||||
@@ -20,12 +20,12 @@ import (
|
||||
func InitDB() {
|
||||
logLevel := logger.Silent
|
||||
if flags.Debug || flags.Dev {
|
||||
logLevel = logger.Info
|
||||
logLevel = logger.Warn // Warn 级别:只输出慢查询和错误,不输出每条 SQL
|
||||
}
|
||||
newLogger := logger.New(
|
||||
stdlog.New(log.StandardLogger().Out, "\r\n", stdlog.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
SlowThreshold: 200 * time.Millisecond, // 超过 200ms 才记录慢查询
|
||||
LogLevel: logLevel,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/transcode"
|
||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||
"github.com/OpenListTeam/OpenList/v4/server"
|
||||
"github.com/OpenListTeam/OpenList/v4/server/middlewares"
|
||||
@@ -92,6 +93,10 @@ func Start() {
|
||||
InitOfflineDownloadTools()
|
||||
LoadStorages()
|
||||
InitTaskManager()
|
||||
// 初始化转码模块(总开关关时也会启动 manager,但本地 worker 仅在 enabled 时由 Manager.Start 内部按 run_mode 启动)
|
||||
if transcode.IsEnabled() {
|
||||
transcode.Default().Start()
|
||||
}
|
||||
if !flags.Debug && !flags.Dev {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,45 @@ const (
|
||||
StreamMaxClientUploadSpeed = "max_client_upload_speed"
|
||||
StreamMaxServerDownloadSpeed = "max_server_download_speed"
|
||||
StreamMaxServerUploadSpeed = "max_server_upload_speed"
|
||||
|
||||
// media
|
||||
MediaTMDBKey = "media_tmdb_key"
|
||||
MediaTMDBAPIURL = "media_tmdb_api_url"
|
||||
MediaDiscogsToken = "media_discogs_token"
|
||||
MediaDiscogsAPIURL = "media_discogs_api_url"
|
||||
MediaThumbnailMode = "media_thumbnail_mode"
|
||||
MediaThumbnailPath = "media_thumbnail_path"
|
||||
MediaStoreThumbnail = "media_store_thumbnail"
|
||||
MediaScrapeConcurrency = "media_scrape_concurrency"
|
||||
|
||||
// transcode (云端/本地 FFmpeg 转码)
|
||||
TranscodeEnabled = "transcode_enabled" // 总开关,默认关
|
||||
TranscodeMinSizeGB = "transcode_min_size_gb" // 文件大于多少 GB 才转码
|
||||
TranscodeMinBitrateMbps = "transcode_min_bitrate_mbps" // 视频码率高于多少 Mbps 才转码(0=不限)
|
||||
TranscodeSourceCodecs = "transcode_source_codecs" // 仅对这些源编码进行转码
|
||||
TranscodeSourceExtensions = "transcode_source_extensions" // 仅对这些后缀进行转码
|
||||
TranscodeOutputFormat = "transcode_output_format" // 输出封装格式 hls/dash/mp4
|
||||
TranscodeOutputCodec = "transcode_output_codec" // 重新编码的视频编码
|
||||
TranscodeOutputBitrate = "transcode_output_bitrate" // 输出视频码率,例如 4000k
|
||||
TranscodeOutputAudioCodec = "transcode_output_audio_codec" // 输出音频编码
|
||||
TranscodeOutputAudioBitrate = "transcode_output_audio_bitrate" // 输出音频码率,例如 160k
|
||||
TranscodeOutputResolution = "transcode_output_resolution" // 输出分辨率上限
|
||||
TranscodeSegmentDuration = "transcode_segment_duration" // HLS 切片时长(秒)
|
||||
TranscodeHWAccel = "transcode_hwaccel" // 硬件加速类型 none/auto/nvenc/qsv/vaapi/amf/videotoolbox
|
||||
TranscodeFFmpegPath = "transcode_ffmpeg_path" // 自定义 ffmpeg 可执行路径
|
||||
TranscodeFFprobePath = "transcode_ffprobe_path" // 自定义 ffprobe 可执行路径
|
||||
TranscodeRunMode = "transcode_run_mode" // local / remote / hybrid
|
||||
TranscodeWorkerSecret = "transcode_worker_secret" // 远程 Worker 共享密钥
|
||||
TranscodeCachePath = "transcode_cache_path" // 切片缓存目录
|
||||
TranscodeCacheMaxGB = "transcode_cache_max_gb" // 缓存最大占用,超出 LRU 淘汰
|
||||
TranscodeJobTimeoutMin = "transcode_job_timeout_min" // 单个任务超时分钟
|
||||
TranscodeLocalConcurrency = "transcode_local_concurrency" // 本地内置 worker 并发数(run_mode=local/hybrid 时生效)
|
||||
TranscodeIdleTimeoutSec = "transcode_idle_timeout_sec" // 播放端无请求多少秒后自动停止转码(默认90秒)
|
||||
// === 智能 chunk 调度相关 ===
|
||||
TranscodeChunkDurationSec = "transcode_chunk_duration_sec" // 每个 chunk 时长(秒),将整个视频分成多个 chunk 按需转码(默认60秒)
|
||||
TranscodeMaxChunkConcurrency = "transcode_max_chunk_concurrency" // 单个 Job 最多同时运行的 chunk 数(默认2)
|
||||
TranscodeChunkIdleSec = "transcode_chunk_idle_sec" // chunk 多久没被访问就 kill ffmpeg 释放资源(默认60秒)
|
||||
TranscodeChunkPrefetch = "transcode_chunk_prefetch" // 当前 chunk 转码进度过半时,是否预启动下一个 chunk(默认true)
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+39
-1
@@ -12,12 +12,50 @@ var db *gorm.DB
|
||||
|
||||
func Init(d *gorm.DB) {
|
||||
db = d
|
||||
err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB))
|
||||
// 迁移前处理:处理 media_items 的存储语义变更,避免旧数据与新代码不一致
|
||||
// 语义变更:folder_path 现为文件实际父目录(之前为扫描根路径),旧数据需重扫
|
||||
migrateMediaItems()
|
||||
err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.MediaItem), new(model.MediaConfig), new(model.MediaScanPath))
|
||||
if err != nil {
|
||||
log.Fatalf("failed migrate database: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// migrateMediaItems 处理 media_items 表的迁移兼容性
|
||||
// 存储语义历史变更:
|
||||
// v1: folder_path 为文件完整路径
|
||||
// v2: folder_path 恒定为扫描根路径
|
||||
// v3(当前): folder_path 为文件/文件夹的实际父目录,供前端拼接播放地址
|
||||
// 唯一性由 folder_path + file_name + album_name 组合索引保证
|
||||
func migrateMediaItems() {
|
||||
// 检查表是否存在
|
||||
if !db.Migrator().HasTable("x_media_items") {
|
||||
return
|
||||
}
|
||||
// 语义标记:在设置表中写一个版本号,用来检测是否需要重扫。
|
||||
// 这里采用轻量方式:检查是否存在以 sp.Path 开头的 folder_path 且可能丢了子路径的记录。
|
||||
// 为避免复杂判断,直接在检测到旧版本索引时清空数据。
|
||||
if db.Migrator().HasIndex("x_media_items", "idx_media_folder_file_album") {
|
||||
// 已是新索引:仅清理软删除残留数据,避免与唯一索引冲突
|
||||
if err := db.Exec("DELETE FROM x_media_items WHERE deleted_at IS NOT NULL").Error; err != nil {
|
||||
log.Warnf("media_items: 清理软删除残留数据失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 旧表存在但没有新组合索引,说明是旧版本数据,需要清空后重建
|
||||
// 旧数据的 folder_path 语义已变更,无法复用
|
||||
log.Info("media_items: 检测到旧版本数据,清空后重新迁移(存储结构已变更)")
|
||||
// 先尝试删除旧的单字段唯一索引(如果存在),避免 AutoMigrate 冲突
|
||||
if db.Migrator().HasIndex("x_media_items", "idx_x_media_items_folder_path") {
|
||||
if err := db.Migrator().DropIndex("x_media_items", "idx_x_media_items_folder_path"); err != nil {
|
||||
log.Warnf("media_items: 删除旧唯一索引失败: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("DELETE FROM x_media_items").Error; err != nil {
|
||||
log.Warnf("media_items: 清空旧数据失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func AutoMigrate(dst ...interface{}) error {
|
||||
var err error
|
||||
if conf.Conf.Database.Type == "mysql" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
stdpath "path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||
)
|
||||
|
||||
// FillMusicFromLocal 在刮削前/失败时,使用音乐文件本身的元数据(ID3 / Vorbis Comment)
|
||||
// 以及同目录 .lrc 歌词,**只填充 item 中为空的字段**。
|
||||
//
|
||||
// 适用场景:
|
||||
// 1. 用户清空刮削后重新刮削,原本由扫描阶段填入的本地数据(封面/歌词等)已丢失
|
||||
// 2. 在线刮削失败,退而求其次用文件本地信息补全
|
||||
//
|
||||
// 行为:
|
||||
// - is_folder=true 的合并文件夹模式:使用 episodes 中第一首音乐文件读取
|
||||
// - is_folder=false:使用 folder_path/file_name 直接读取
|
||||
//
|
||||
// 错误以静默忽略为主——本地兜底失败不应阻塞主流程。
|
||||
func FillMusicFromLocal(ctx context.Context, item *model.MediaItem) {
|
||||
if item == nil || item.MediaType != model.MediaTypeMusic {
|
||||
return
|
||||
}
|
||||
|
||||
musicPath := pickMusicFilePath(item)
|
||||
if musicPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 tag(mp3 / flac / 其它)
|
||||
ext := strings.ToLower(stdpath.Ext(musicPath))
|
||||
var tag *MusicTag
|
||||
readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
if reader := FetchFileReader(readCtx, musicPath); reader != nil {
|
||||
switch ext {
|
||||
case ".flac":
|
||||
tag, _ = ParseFLACVorbisComment(reader)
|
||||
default:
|
||||
tag, _ = ParseID3v2(reader)
|
||||
}
|
||||
_ = reader.Close()
|
||||
}
|
||||
readCancel()
|
||||
|
||||
// 应用 tag 到空字段
|
||||
if tag != nil {
|
||||
if item.AlbumName == "" && tag.Album != "" {
|
||||
item.AlbumName = tag.Album
|
||||
}
|
||||
if item.AlbumArtist == "" {
|
||||
if tag.AlbumArtist != "" {
|
||||
item.AlbumArtist = tag.AlbumArtist
|
||||
} else if tag.Artist != "" {
|
||||
item.AlbumArtist = tag.Artist
|
||||
}
|
||||
}
|
||||
if item.ScrapedName == "" {
|
||||
if tag.Title != "" {
|
||||
item.ScrapedName = tag.Title
|
||||
} else if item.AlbumName != "" {
|
||||
item.ScrapedName = item.AlbumName
|
||||
}
|
||||
}
|
||||
if item.TrackNumber == 0 && tag.TrackNumber > 0 {
|
||||
item.TrackNumber = tag.TrackNumber
|
||||
}
|
||||
if item.ReleaseDate == "" && len(tag.Year) >= 4 {
|
||||
item.ReleaseDate = tag.Year[:4] + "-01-01"
|
||||
}
|
||||
if item.Genre == "" && tag.Genre != "" {
|
||||
item.Genre = tag.Genre
|
||||
}
|
||||
if item.Authors == "" && tag.Artist != "" {
|
||||
if b, err := json.Marshal([]string{tag.Artist}); err == nil {
|
||||
item.Authors = string(b)
|
||||
}
|
||||
}
|
||||
if item.Cover == "" && len(tag.CoverData) > 0 {
|
||||
mimeType := tag.CoverMIME
|
||||
if mimeType == "" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
item.Cover = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData)
|
||||
}
|
||||
}
|
||||
|
||||
// 歌词:若为空则尝试 .lrc 同名文件 / 内嵌歌词
|
||||
if item.Lyrics == "" {
|
||||
if lrc := loadLyricsForMusic(ctx, musicPath, tag); lrc != "" {
|
||||
item.Lyrics = lrc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pickMusicFilePath 从 MediaItem 推断出可读取的音乐文件 VFS 路径
|
||||
// 合并模式 (is_folder=true): 取 episodes 中第一首;解析失败回退到 folder_path/file_name
|
||||
// 普通模式: folder_path/file_name
|
||||
func pickMusicFilePath(item *model.MediaItem) string {
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
if item.IsFolder {
|
||||
// episodes 是 JSON 数组:[{file_name,index,title,...}]
|
||||
if item.Episodes != "" {
|
||||
var eps []EpisodeInfo
|
||||
if err := json.Unmarshal([]byte(item.Episodes), &eps); err == nil && len(eps) > 0 {
|
||||
// 文件夹模式下 folder_path 是扫描根,file_name 是文件夹名
|
||||
return stdpath.Join(item.FolderPath, item.FileName, eps[0].FileName)
|
||||
}
|
||||
}
|
||||
// 回退:尝试列出文件夹内第一首音乐
|
||||
folder := stdpath.Join(item.FolderPath, item.FileName)
|
||||
if entries, err := fs.List(context.Background(), folder, &fs.ListArgs{NoLog: true}); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && isMediaFile(e.GetName(), model.MediaTypeMusic) {
|
||||
return stdpath.Join(folder, e.GetName())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return stdpath.Join(item.FolderPath, item.FileName)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MediaType 媒体类型,使用字符串便于后期扩展
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
MediaTypeVideo MediaType = "video"
|
||||
MediaTypeMusic MediaType = "music"
|
||||
MediaTypeImage MediaType = "image"
|
||||
MediaTypeBook MediaType = "book"
|
||||
)
|
||||
|
||||
// MediaScanPath 媒体库扫描路径(每个媒体库可配置多个扫描路径)
|
||||
type MediaScanPath struct {
|
||||
gorm.Model
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
MediaType MediaType `gorm:"index;not null" json:"media_type"` // 所属媒体类型
|
||||
Name string `json:"name"` // 路径名称(用于前端筛选显示)
|
||||
Path string `gorm:"not null" json:"path"` // VFS 扫描路径
|
||||
PathMerge bool `gorm:"default:false" json:"path_merge"` // 路径合并模式(子文件夹作为一个条目)
|
||||
TypeTag string `json:"type_tag"` // 类型标签:电影、电视剧等
|
||||
ContentTags string `json:"content_tags"` // 内容标签:喜剧、惊悚等(逗号分隔)
|
||||
EnableScrape bool `gorm:"default:true" json:"enable_scrape"` // 是否启用刮削
|
||||
LastScanAt *time.Time `json:"last_scan_at"` // 最后扫描时间
|
||||
}
|
||||
|
||||
// MediaItem 媒体条目(统一表,通过 media_type 区分类型,便于后期扩展新类型)
|
||||
type MediaItem struct {
|
||||
gorm.Model
|
||||
// 覆盖 gorm.Model 的 ID 字段,使 JSON 序列化为小写 "id",与前端保持一致
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
// 基础信息
|
||||
MediaType MediaType `gorm:"index;not null" json:"media_type"`
|
||||
ScanPathID uint `gorm:"index" json:"scan_path_id"` // 关联的扫描路径ID
|
||||
FileName string `gorm:"uniqueIndex:idx_media_folder_file_album" json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Hidden bool `gorm:"default:false" json:"hidden"`
|
||||
|
||||
// 刮削/编辑信息
|
||||
ScrapedName string `json:"scraped_name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Cover string `json:"cover"` // 封面URL或本地路径
|
||||
ReleaseDate string `json:"release_date"` // 发布时间,格式 YYYY-MM-DD
|
||||
Rating float32 `json:"rating"` // 评分 0-10
|
||||
Genre string `json:"genre"` // 类型,逗号分隔,如 "动作,科幻"
|
||||
Authors string `gorm:"type:text" json:"authors"` // 作者/演员,JSON数组字符串
|
||||
Plot string `gorm:"type:text" json:"plot"` // 剧情/内容介绍
|
||||
Reviews string `gorm:"type:text" json:"reviews"` // 用户评价,JSON数组字符串
|
||||
|
||||
// 外部ID(用于刮削关联)
|
||||
ExternalID string `json:"external_id"` // TMDB ID / Discogs ID / 豆瓣ID
|
||||
|
||||
// 音乐专属字段
|
||||
AlbumName string `gorm:"uniqueIndex:idx_media_folder_file_album" json:"album_name"` // 所属专辑名
|
||||
AlbumArtist string `json:"album_artist"` // 专辑艺术家
|
||||
TrackNumber int `json:"track_number"` // 曲目编号
|
||||
Duration int `json:"duration"` // 时长(秒)
|
||||
Lyrics string `gorm:"type:text" json:"lyrics"` // LRC格式歌词
|
||||
|
||||
// 视频专属字段
|
||||
VideoType string `json:"video_type"` // "movie" 或 "tv"
|
||||
Season int `json:"season"` // 季(电视剧)
|
||||
Episode int `json:"episode"` // 集(电视剧)
|
||||
|
||||
// 书籍专属字段
|
||||
Publisher string `json:"publisher"` // 出版社
|
||||
ISBN string `json:"isbn"` // ISBN
|
||||
|
||||
// 目录合并模式
|
||||
IsFolder bool `gorm:"default:false" json:"is_folder"` // 是否为文件夹模式条目
|
||||
FolderPath string `gorm:"index;uniqueIndex:idx_media_folder_file_album" json:"folder_path"` // 扫描根路径(恒定为扫描路径,与file_name+album_name组合唯一)
|
||||
Episodes string `gorm:"type:text" json:"episodes"` // 选集信息,JSON数组,格式:[{file_name,index,title},...]
|
||||
|
||||
ScrapedAt *time.Time `json:"scraped_at"`
|
||||
}
|
||||
|
||||
// MediaConfig 媒体库配置(每种类型一条记录)
|
||||
type MediaConfig struct {
|
||||
gorm.Model
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
MediaType MediaType `gorm:"uniqueIndex;not null" json:"media_type"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
LastScanAt *time.Time `json:"last_scan_at"`
|
||||
LastScrapeAt *time.Time `json:"last_scrape_at"`
|
||||
}
|
||||
|
||||
// MediaScanProgress 扫描进度(内存中维护,不持久化)
|
||||
type MediaScanProgress struct {
|
||||
MediaType MediaType `json:"media_type"`
|
||||
ScanPathID uint `json:"scan_path_id,omitempty"` // 0 表示全部路径
|
||||
Running bool `json:"running"`
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user