mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 21:13:10 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f977f32dba | |||
| c8306256ff | |||
| 5273a77854 | |||
| cc3d8a06a7 | |||
| 925a2422d6 | |||
| 33015eee9f | |||
| 1be74776f6 | |||
| 31810f556c | |||
| 30922b6d52 | |||
| 759adfca21 | |||
| bfbc441fa6 | |||
| 866793f485 | |||
| 40b39000ab | |||
| 004a15bc5e | |||
| 9ac2f3b7f0 | |||
| 756862d1e4 |
@@ -1,153 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.0"
|
go-version: "1.26.4"
|
||||||
|
|
||||||
- name: Setup web
|
- name: Setup web
|
||||||
run: bash build.sh dev web
|
run: bash build.sh dev web
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.0"
|
go-version: "1.26.4"
|
||||||
|
|
||||||
- name: Setup web
|
- name: Setup web
|
||||||
run: bash build.sh dev web
|
run: bash build.sh dev web
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25.0'
|
go-version: '1.26.4'
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25.0'
|
go-version: '1.26'
|
||||||
|
|
||||||
- name: Cache Musl
|
- name: Cache Musl
|
||||||
id: cache-musl
|
id: cache-musl
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25.0'
|
go-version: '1.26.4'
|
||||||
|
|
||||||
- name: Cache Musl
|
- name: Cache Musl
|
||||||
id: cache-musl
|
id: cache-musl
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.25.0'
|
go-version: '1.26.4'
|
||||||
|
|
||||||
- name: Cache Musl
|
- name: Cache Musl
|
||||||
id: cache-musl
|
id: cache-musl
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ BuildRelease() {
|
|||||||
BuildLoongGLIBC() {
|
BuildLoongGLIBC() {
|
||||||
local target_abi="$2"
|
local target_abi="$2"
|
||||||
local output_file="$1"
|
local output_file="$1"
|
||||||
local oldWorldGoVersion="1.25.0"
|
local oldWorldGoVersion="1.26.4"
|
||||||
local loong_tags
|
local loong_tags
|
||||||
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
|
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
|
||||||
|
|
||||||
@@ -309,13 +309,13 @@ BuildLoongGLIBC() {
|
|||||||
|
|
||||||
# Download and setup patched Go compiler for old-world
|
# Download and setup patched Go compiler for old-world
|
||||||
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250821/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||||
-o go-loong64-abi1.0.tar.gz; then
|
-o go-loong64-abi1.0.tar.gz; then
|
||||||
echo "Error: Failed to download patched Go compiler for old-world ABI1.0"
|
echo "Error: Failed to download patched Go compiler for old-world ABI1.0"
|
||||||
if [ -n "$GITHUB_TOKEN" ]; then
|
if [ -n "$GITHUB_TOKEN" ]; then
|
||||||
echo "Error output from curl:"
|
echo "Error output from curl:"
|
||||||
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250821/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
|
||||||
-o go-loong64-abi1.0.tar.gz || true
|
-o go-loong64-abi1.0.tar.gz || true
|
||||||
fi
|
fi
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
|||||||
# 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 永久免版税
|
|
||||||
+30
-1
@@ -262,6 +262,16 @@ func (y *Cloud189PC) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.
|
|||||||
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
|
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跟随移动 torrent 文件
|
||||||
|
if !srcObj.IsDir() {
|
||||||
|
var srcFolderId string
|
||||||
|
if f, ok := srcObj.(*Cloud189File); ok {
|
||||||
|
srcFolderId = f.ParentID
|
||||||
|
}
|
||||||
|
y.torrentFollowMove(srcFolderId, srcObj.GetName(), dstDir)
|
||||||
|
}
|
||||||
|
|
||||||
return srcObj, nil
|
return srcObj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +308,12 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跟随重命名 torrent 文件
|
||||||
|
if f, ok := srcObj.(*Cloud189File); ok {
|
||||||
|
y.torrentFollowRename(f.ParentID, srcObj.GetName(), newName)
|
||||||
|
}
|
||||||
|
|
||||||
switch f := srcObj.(type) {
|
switch f := srcObj.(type) {
|
||||||
case *Cloud189File:
|
case *Cloud189File:
|
||||||
return resp.toFile(f), nil
|
return resp.toFile(f), nil
|
||||||
@@ -319,7 +335,20 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return y.WaitBatchTask("COPY", resp.TaskID, time.Second)
|
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跟随复制 torrent 文件
|
||||||
|
if !srcObj.IsDir() {
|
||||||
|
var srcFolderId string
|
||||||
|
if f, ok := srcObj.(*Cloud189File); ok {
|
||||||
|
srcFolderId = f.ParentID
|
||||||
|
}
|
||||||
|
y.torrentFollowCopy(srcFolderId, srcObj.GetName(), dstDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (y *Cloud189PC) Remove(ctx context.Context, obj model.Obj) error {
|
func (y *Cloud189PC) Remove(ctx context.Context, obj model.Obj) error {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package _189pc
|
package _189pc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -8,10 +9,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
|
|
||||||
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||||
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
|
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
|
||||||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||||
)
|
)
|
||||||
@@ -162,6 +166,36 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
|
|||||||
return nil, fmt.Errorf("提交上传失败: %w", err)
|
return nil, fmt.Errorf("提交上传失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 秒传成功后,将 torrent 文件上传到目标目录(异步,不影响秒传结果)
|
||||||
|
if y.Addition.GenerateTorrent {
|
||||||
|
capturedDstDir := dstDir
|
||||||
|
capturedIsFamily := isFamily
|
||||||
|
go func() {
|
||||||
|
torrentName := fileName + ".cas.torrent"
|
||||||
|
infoHash, _ := GetInfoHashHex(torrentData)
|
||||||
|
utils.Log.Infof("秒传成功,上传 torrent: %s (info_hash: %s, size: %d bytes)",
|
||||||
|
torrentName, infoHash, len(torrentData))
|
||||||
|
|
||||||
|
torrentFileStream := &stream.FileStream{
|
||||||
|
Ctx: context.Background(),
|
||||||
|
Obj: &model.Object{
|
||||||
|
Name: torrentName,
|
||||||
|
Size: int64(len(torrentData)),
|
||||||
|
IsFolder: false,
|
||||||
|
},
|
||||||
|
Reader: bytes.NewReader(torrentData),
|
||||||
|
Mimetype: "application/x-bittorrent",
|
||||||
|
}
|
||||||
|
_, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false)
|
||||||
|
if uploadErr != nil {
|
||||||
|
utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr)
|
||||||
|
} else {
|
||||||
|
utils.Log.Infof("torrent 文件已上传: %s", torrentName)
|
||||||
|
op.Cache.DeleteDirectory(y, capturedDstDir.GetPath())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return resp.toFile(), nil
|
return resp.toFile(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +292,10 @@ func GetInfoHashHex(torrentData []byte) (string, error) {
|
|||||||
return hex.EncodeToString(t.InfoHash), nil
|
return hex.EncodeToString(t.InfoHash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCASTorrentFile(fileName string) bool {
|
||||||
|
return strings.HasSuffix(fileName, ".cas.torrent")
|
||||||
|
}
|
||||||
|
|
||||||
// ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5
|
// ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5
|
||||||
// 返回:整文件 MD5、分片 MD5 列表
|
// 返回:整文件 MD5、分片 MD5 列表
|
||||||
func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []string, error) {
|
func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []string, error) {
|
||||||
@@ -289,3 +327,123 @@ func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []st
|
|||||||
fileMD5Hex := strings.ToUpper(hex.EncodeToString(fileMD5Hash.Sum(nil)))
|
fileMD5Hex := strings.ToUpper(hex.EncodeToString(fileMD5Hash.Sum(nil)))
|
||||||
return fileMD5Hex, sliceMD5s, nil
|
return fileMD5Hex, sliceMD5s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// torrentFollowCopy 跟随复制 torrent 文件(异步,不影响主操作)
|
||||||
|
// srcFolderId: 源文件所在目录 ID
|
||||||
|
// srcFileName: 源文件名
|
||||||
|
// dstDir: 目标目录
|
||||||
|
func (y *Cloud189PC) torrentFollowCopy(srcFolderId string, srcFileName string, dstDir model.Obj) {
|
||||||
|
if !y.Addition.GenerateTorrent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if srcFolderId == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
torrentName := srcFileName + ".cas.torrent"
|
||||||
|
isFamily := y.isFamily()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
|
||||||
|
if err != nil {
|
||||||
|
utils.Log.Debugf("查找 torrent 文件失败(跟随复制): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 复制 torrent 文件到目标目录
|
||||||
|
resp, copyErr := y.CreateBatchTask("COPY", IF(isFamily, y.FamilyID, ""), dstDir.GetID(),
|
||||||
|
map[string]string{"targetFileName": dstDir.GetName()},
|
||||||
|
BatchTaskInfo{
|
||||||
|
FileId: torrentFile.GetID(),
|
||||||
|
FileName: torrentFile.GetName(),
|
||||||
|
IsFolder: 0,
|
||||||
|
})
|
||||||
|
if copyErr != nil {
|
||||||
|
utils.Log.Warnf("跟随复制 torrent 文件失败: %v", copyErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
|
||||||
|
utils.Log.Warnf("等待跟随复制 torrent 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// torrentFollowMove 跟随移动 torrent 文件(异步,不影响主操作)
|
||||||
|
// srcFolderId: 源文件所在目录 ID
|
||||||
|
// srcFileName: 源文件名
|
||||||
|
// dstDir: 目标目录
|
||||||
|
func (y *Cloud189PC) torrentFollowMove(srcFolderId string, srcFileName string, dstDir model.Obj) {
|
||||||
|
if !y.Addition.GenerateTorrent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if srcFolderId == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
torrentName := srcFileName + ".cas.torrent"
|
||||||
|
isFamily := y.isFamily()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
|
||||||
|
if err != nil {
|
||||||
|
utils.Log.Debugf("查找 torrent 文件失败(跟随移动): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 移动 torrent 文件到目标目录
|
||||||
|
resp, moveErr := y.CreateBatchTask("MOVE", IF(isFamily, y.FamilyID, ""), dstDir.GetID(),
|
||||||
|
map[string]string{"targetFileName": dstDir.GetName()},
|
||||||
|
BatchTaskInfo{
|
||||||
|
FileId: torrentFile.GetID(),
|
||||||
|
FileName: torrentFile.GetName(),
|
||||||
|
IsFolder: 0,
|
||||||
|
})
|
||||||
|
if moveErr != nil {
|
||||||
|
utils.Log.Warnf("跟随移动 torrent 文件失败: %v", moveErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
|
||||||
|
utils.Log.Warnf("等待跟随移动 torrent 文件失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// torrentFollowRename 跟随重命名 torrent 文件(异步,不影响主操作)
|
||||||
|
// folderId: 文件所在目录 ID
|
||||||
|
// oldFileName: 原文件名
|
||||||
|
// newFileName: 新文件名
|
||||||
|
func (y *Cloud189PC) torrentFollowRename(folderId string, oldFileName string, newFileName string) {
|
||||||
|
if !y.Addition.GenerateTorrent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if folderId == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldTorrentName := oldFileName + ".cas.torrent"
|
||||||
|
newTorrentName := newFileName + ".cas.torrent"
|
||||||
|
isFamily := y.isFamily()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
torrentFile, err := y.findFileByName(context.Background(), oldTorrentName, folderId, isFamily)
|
||||||
|
if err != nil {
|
||||||
|
utils.Log.Debugf("查找 torrent 文件失败(跟随重命名): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重命名 torrent 文件
|
||||||
|
queryParam := make(map[string]string)
|
||||||
|
fullUrl := API_URL
|
||||||
|
method := "POST"
|
||||||
|
if isFamily {
|
||||||
|
fullUrl += "/family/file"
|
||||||
|
method = "GET"
|
||||||
|
queryParam["familyId"] = y.FamilyID
|
||||||
|
}
|
||||||
|
fullUrl += "/renameFile.action"
|
||||||
|
queryParam["fileId"] = torrentFile.GetID()
|
||||||
|
queryParam["destFileName"] = newTorrentName
|
||||||
|
|
||||||
|
_, renameErr := y.request(fullUrl, method, func(req *resty.Request) {
|
||||||
|
req.SetContext(context.Background()).SetQueryParams(queryParam)
|
||||||
|
}, nil, &RenameResp{}, isFamily)
|
||||||
|
if renameErr != nil {
|
||||||
|
utils.Log.Warnf("跟随重命名 torrent 文件失败: %v", renameErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,10 +166,11 @@ type FamilyInfoResp struct {
|
|||||||
/*文件部分*/
|
/*文件部分*/
|
||||||
// 文件
|
// 文件
|
||||||
type Cloud189File struct {
|
type Cloud189File struct {
|
||||||
ID String `json:"id"`
|
ID String `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Md5 string `json:"md5"`
|
Md5 string `json:"md5"`
|
||||||
|
ParentID string `json:"-"` // 由 getFiles 设置,不从 JSON 解析
|
||||||
|
|
||||||
LastOpTime Time `json:"lastOpTime"`
|
LastOpTime Time `json:"lastOpTime"`
|
||||||
CreateDate Time `json:"createDate"`
|
CreateDate Time `json:"createDate"`
|
||||||
|
|||||||
+63
-1
@@ -210,6 +210,7 @@ func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool)
|
|||||||
res = append(res, &resp.FileListAO.FolderList[i])
|
res = append(res, &resp.FileListAO.FolderList[i])
|
||||||
}
|
}
|
||||||
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
for i := 0; i < len(resp.FileListAO.FileList); i++ {
|
||||||
|
resp.FileListAO.FileList[i].ParentID = fileId
|
||||||
res = append(res, &resp.FileListAO.FileList[i])
|
res = append(res, &resp.FileListAO.FileList[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -910,6 +911,11 @@ func (y *Cloud189PC) RapidUpload(ctx context.Context, dstDir model.Obj, stream m
|
|||||||
|
|
||||||
// 快传
|
// 快传
|
||||||
func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) {
|
func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) {
|
||||||
|
generateTorrent := y.Addition.GenerateTorrent && !isCASTorrentFile(file.GetName())
|
||||||
|
return y.fastUpload(ctx, dstDir, file, up, isFamily, overwrite, generateTorrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *Cloud189PC) fastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool, generateTorrent bool) (model.Obj, error) {
|
||||||
var (
|
var (
|
||||||
cache = file.GetFile()
|
cache = file.GetFile()
|
||||||
tmpF *os.File
|
tmpF *os.File
|
||||||
@@ -947,6 +953,9 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
|||||||
if tmpF != nil {
|
if tmpF != nil {
|
||||||
writers = append(writers, tmpF)
|
writers = append(writers, tmpF)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pieceSHA1Hashes := make([]byte, 0, count*20)
|
||||||
|
|
||||||
written := int64(0)
|
written := int64(0)
|
||||||
for i := 1; i <= count; i++ {
|
for i := 1; i <= count; i++ {
|
||||||
if utils.IsCanceled(ctx) {
|
if utils.IsCanceled(ctx) {
|
||||||
@@ -957,7 +966,17 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
|||||||
byteSize = lastSliceSize
|
byteSize = lastSliceSize
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := utils.CopyWithBufferN(io.MultiWriter(writers...), file, byteSize)
|
// 如果需要生成 torrent,同时计算 SHA-1
|
||||||
|
var sha1Writer hash.Hash
|
||||||
|
var multiWriter io.Writer
|
||||||
|
if generateTorrent {
|
||||||
|
sha1Writer = sha1Pkg.New()
|
||||||
|
multiWriter = io.MultiWriter(append(writers, sha1Writer)...)
|
||||||
|
} else {
|
||||||
|
multiWriter = io.MultiWriter(writers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := utils.CopyWithBufferN(multiWriter, file, byteSize)
|
||||||
written += n
|
written += n
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -966,6 +985,11 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
|||||||
sliceMd5Hexs = append(sliceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Byte)))
|
sliceMd5Hexs = append(sliceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Byte)))
|
||||||
partInfos = append(partInfos, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte)))
|
partInfos = append(partInfos, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte)))
|
||||||
sliceMd5.Reset()
|
sliceMd5.Reset()
|
||||||
|
|
||||||
|
// 收集 SHA-1 piece hash(仅在本次分片实际写入了数据时追加)
|
||||||
|
if generateTorrent && n > 0 {
|
||||||
|
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Writer.Sum(nil)...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tmpF != nil {
|
if tmpF != nil {
|
||||||
@@ -1081,6 +1105,44 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成 torrent 文件(异步,不影响上传结果)
|
||||||
|
if generateTorrent && size > 0 && len(pieceSHA1Hashes) > 0 {
|
||||||
|
capturedDstDir := dstDir
|
||||||
|
capturedIsFamily := isFamily
|
||||||
|
capturedFileName := file.GetName()
|
||||||
|
go func() {
|
||||||
|
torrentData, err := GenerateTorrent(capturedFileName, size, fileMd5Hex, sliceMd5Hexs, sliceSize, pieceSHA1Hashes)
|
||||||
|
if err != nil {
|
||||||
|
utils.Log.Warnf("生成 torrent 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
infoHash, _ := GetInfoHashHex(torrentData)
|
||||||
|
torrentName := capturedFileName + ".cas.torrent"
|
||||||
|
utils.Log.Infof("已生成 torrent: %s (info_hash: %s, size: %d bytes)",
|
||||||
|
torrentName, infoHash, len(torrentData))
|
||||||
|
|
||||||
|
// 将 torrent 文件上传到同一目录
|
||||||
|
torrentFileStream := &stream.FileStream{
|
||||||
|
Ctx: context.Background(),
|
||||||
|
Obj: &model.Object{
|
||||||
|
Name: torrentName,
|
||||||
|
Size: int64(len(torrentData)),
|
||||||
|
IsFolder: false,
|
||||||
|
},
|
||||||
|
Reader: bytes.NewReader(torrentData),
|
||||||
|
Mimetype: "application/x-bittorrent",
|
||||||
|
}
|
||||||
|
_, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false)
|
||||||
|
if uploadErr != nil {
|
||||||
|
utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr)
|
||||||
|
} else {
|
||||||
|
utils.Log.Infof("torrent 文件已上传: %s", torrentName)
|
||||||
|
op.Cache.DeleteDirectory(y, capturedDstDir.GetPath())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return resp.toFile(), nil
|
return resp.toFile(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,79 +249,6 @@ func InitialSettings() []model.SettingItem {
|
|||||||
{Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
|
{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.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},
|
{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()
|
additionalSettingItems := tool.Tools.Items()
|
||||||
// 固定顺序
|
// 固定顺序
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ import (
|
|||||||
func InitDB() {
|
func InitDB() {
|
||||||
logLevel := logger.Silent
|
logLevel := logger.Silent
|
||||||
if flags.Debug || flags.Dev {
|
if flags.Debug || flags.Dev {
|
||||||
logLevel = logger.Warn // Warn 级别:只输出慢查询和错误,不输出每条 SQL
|
logLevel = logger.Info
|
||||||
}
|
}
|
||||||
newLogger := logger.New(
|
newLogger := logger.New(
|
||||||
stdlog.New(log.StandardLogger().Out, "\r\n", stdlog.LstdFlags),
|
stdlog.New(log.StandardLogger().Out, "\r\n", stdlog.LstdFlags),
|
||||||
logger.Config{
|
logger.Config{
|
||||||
SlowThreshold: 200 * time.Millisecond, // 超过 200ms 才记录慢查询
|
SlowThreshold: time.Second,
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
IgnoreRecordNotFoundError: true,
|
IgnoreRecordNotFoundError: true,
|
||||||
Colorful: true,
|
Colorful: true,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||||
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
"github.com/OpenListTeam/OpenList/v4/internal/db"
|
||||||
"github.com/OpenListTeam/OpenList/v4/internal/fs"
|
"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/pkg/utils"
|
||||||
"github.com/OpenListTeam/OpenList/v4/server"
|
"github.com/OpenListTeam/OpenList/v4/server"
|
||||||
"github.com/OpenListTeam/OpenList/v4/server/middlewares"
|
"github.com/OpenListTeam/OpenList/v4/server/middlewares"
|
||||||
@@ -93,10 +92,6 @@ func Start() {
|
|||||||
InitOfflineDownloadTools()
|
InitOfflineDownloadTools()
|
||||||
LoadStorages()
|
LoadStorages()
|
||||||
InitTaskManager()
|
InitTaskManager()
|
||||||
// 初始化转码模块(总开关关时也会启动 manager,但本地 worker 仅在 enabled 时由 Manager.Start 内部按 run_mode 启动)
|
|
||||||
if transcode.IsEnabled() {
|
|
||||||
transcode.Default().Start()
|
|
||||||
}
|
|
||||||
if !flags.Debug && !flags.Dev {
|
if !flags.Debug && !flags.Dev {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,45 +161,6 @@ const (
|
|||||||
StreamMaxClientUploadSpeed = "max_client_upload_speed"
|
StreamMaxClientUploadSpeed = "max_client_upload_speed"
|
||||||
StreamMaxServerDownloadSpeed = "max_server_download_speed"
|
StreamMaxServerDownloadSpeed = "max_server_download_speed"
|
||||||
StreamMaxServerUploadSpeed = "max_server_upload_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 (
|
const (
|
||||||
|
|||||||
+1
-39
@@ -12,50 +12,12 @@ var db *gorm.DB
|
|||||||
|
|
||||||
func Init(d *gorm.DB) {
|
func Init(d *gorm.DB) {
|
||||||
db = d
|
db = d
|
||||||
// 迁移前处理:处理 media_items 的存储语义变更,避免旧数据与新代码不一致
|
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))
|
||||||
// 语义变更: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 {
|
if err != nil {
|
||||||
log.Fatalf("failed migrate database: %s", err.Error())
|
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 {
|
func AutoMigrate(dst ...interface{}) error {
|
||||||
var err error
|
var err error
|
||||||
if conf.Conf.Database.Type == "mysql" {
|
if conf.Conf.Database.Type == "mysql" {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user