Compare commits

...

41 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 80b0980eac Apply remaining changes 2026-06-30 08:37:46 +00:00
copilot-swe-agent[bot] 75632de2e6 fix(media): return 404 on missing item_id and fix unscraped item query
- Validate GetMediaItemByID result before spawning goroutine in
  StartMediaScrape; return 404 when item_id > 0 but item is not found
- Replace `description` check with `plot` check in GetUnscrappedItems
  since scrapers populate Plot, not Description, to avoid re-selecting
  already-scraped items on every run
2026-06-30 08:35:06 +00:00
Pikachu Ren b85f82c76d Merge branch 'main' into dev-media 2026-06-30 16:32:07 +08:00
pikachuim 3ce3dc4109 feat(media): 刮削修复写入作品名问题 2026-05-15 19:59:05 +08:00
pikachuim 6a46f0e809 feat(media): 刮削支持自定义刮削数量,避免太占性能 2026-05-15 19:34:26 +08:00
pikachuim 609d0a7b84 feat(media): 刮削支持自定义刮削数量,避免太占性能 2026-05-15 19:24:15 +08:00
pikachuim 9a28f7c09c Merge branch 'dev-media' of https://github.com/OpenListTeam/OpenList into dev-media 2026-05-15 18:36:09 +08:00
pikachuim 6646ffe891 feat(media): 提升刮削精准度,支持模糊刮削 2026-05-15 18:32:15 +08:00
j2rong4cn 20d9d39f83 fix(media): clean up imports and improve FetchFileReader logic 2026-05-15 17:12:20 +08:00
pikachuim ccb74025ea feat(media): 提升刮削精准度,支持模糊刮削 2026-05-15 17:05:09 +08:00
pikachuim 94002a2118 Merge branch 'main' into dev-media 2026-05-15 16:31:09 +08:00
pikachuim e750ff502a feat(media): support cloud codec via ffmpeg 2026-05-15 16:26:24 +08:00
pikachuim 97e885fee6 feat(media): support cloud codec via ffmpeg 2026-05-15 16:01:38 +08:00
pikachuim f891d3df1f Merge branch 'dev-media' of https://github.com/OpenListTeam/OpenList into dev-media 2026-05-15 15:48:52 +08:00
pikachuim 6e9878fe61 feat(media): support cloud codec via ffmpeg 2026-05-15 15:48:23 +08:00
pikachuim 08d9e2eb70 Merge branch 'dev-codec' into dev-media
# Conflicts:
#	internal/bootstrap/data/setting.go   resolved by dev-codec version
#	internal/conf/const.go   resolved by dev-codec version
#	internal/model/setting.go   resolved by dev-codec version
#	server/router.go   resolved by dev-codec version
2026-05-15 15:20:09 +08:00
pikachuim 0386f87110 feat(media): support cloud codec via ffmpeg 2026-05-15 15:16:01 +08:00
Pikachu Ren 4c9e63611f Merge branch 'main' into dev-media 2026-05-14 20:36:45 +08:00
pikachuim b00e694f68 fix(media): fix media scan sub path error 2026-05-14 18:25:57 +08:00
pikachuim ae6f05af11 fix(media): fix lrc file display & music id3 2026-05-14 17:07:35 +08:00
pikachuim 8dbcd0fd88 fix(media): fix api proxy path 2026-05-14 16:28:09 +08:00
pikachuim 2182ccff96 feat(media): 清空刮削、删除已失效 2026-05-14 14:27:54 +08:00
pikachuim b5f72ecdcc feat(media): 并发刮削(默认 5)+ 单条落库
增强 TMDB 搜索:模糊匹配 + 多候选关键词
管理页面新增导入/导出媒体库(全部 / 按扫描路径)
缩略图路径默认值改为 imgs
2026-05-14 14:14:58 +08:00
pikachuim 5a27d5fa6d feat(func): media library support change api url 2026-05-14 12:41:52 +08:00
pikachuim ce6bff2ad8 feat(func): media library support change api url 2026-05-14 12:37:07 +08:00
pikachuim cc1fec59ac feat(func): media library support change api url 2026-05-14 12:25:28 +08:00
Pikachu Ren 29f0636833 Merge branch 'main' into dev-media 2026-04-17 14:25:04 +08:00
pikachuim 8a72c78075 feat(func): media library support select 2026-04-08 21:21:14 +08:00
pikachuim 3527cab26b feat(func): media library support select 2026-04-08 20:32:40 +08:00
pikachuim dc92fccf52 feat(func): media library support select 2026-04-07 23:28:55 +08:00
pikachuim e33ff8d9f7 feat(func): media library support select 2026-04-07 23:07:33 +08:00
pikachuim 7571afcd41 Merge branch 'main' into dev-media
# Conflicts:
#	go.sum   resolved by main version
2026-04-07 23:07:07 +08:00
pikachuim 85c7f85b18 Merge branch 'main' into dev-media 2026-04-07 21:26:33 +08:00
pikachuim 9775971eb7 feat(func): media library fix remote fetch error 2026-04-01 19:31:50 +08:00
pikachuim 9096eb60c2 feat(func): media library fix remote fetch error 2026-04-01 19:30:56 +08:00
pikachuim 51c703c0a8 feat(func): media library fix remote fetch error 2026-04-01 19:23:25 +08:00
pikachuim 0e33727675 feat(func): media library build test 2026-03-25 18:07:25 +08:00
pikachuim 879870ef63 feat(func): media library build test 2026-03-25 17:55:07 +08:00
pikachuim bd84607537 feat(func): media library 2026-03-09 20:56:21 +08:00
pikachuim 0f340111da feat(func): media library 2026-03-09 20:52:55 +08:00
pikachuim e776d2ca43 feat(func): media library 2026-03-09 20:43:16 +08:00
35 changed files with 10399 additions and 20 deletions
+153
View File
@@ -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
+565
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -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 永久免版税
+4 -3
View File
@@ -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
+6 -12
View File
@@ -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=
+73
View File
@@ -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()
// 固定顺序
+2 -2
View File
@@ -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,
+5
View File
@@ -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)
}
+39
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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
+113
View File
@@ -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