Compare commits

..

16 Commits

Author SHA1 Message Date
Pikachu Ren f977f32dba Merge branch 'main' into map 2026-07-01 15:39:48 +08:00
renovate[bot] c8306256ff chore(deps): update go toolchain directive to v1.26.4 (#2697)
* chore(deps): update go toolchain directive to v1.26.4

* chore(build.sh): update go version for loongarch abi1.0

Signed-off-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>

---------

Signed-off-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-01 15:37:27 +08:00
renovate[bot] 5273a77854 fix(deps): update module github.com/gin-contrib/cors to v1.7.7 (#2688)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:36:47 +08:00
renovate[bot] cc3d8a06a7 fix(deps): update module github.com/bodgit/sevenzip to v1.6.4 (#2682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-01 15:32:28 +08:00
renovate[bot] 925a2422d6 chore(deps): update github.com/openlistteam/reflink digest to 78760ea (#2693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-01 15:29:04 +08:00
renovate[bot] 33015eee9f fix(deps): update module github.com/mholt/archives to v0.1.5 (#2690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:28:42 +08:00
renovate[bot] 1be74776f6 fix(deps): update module github.com/ncw/swift/v2 to v2.0.5 (#2691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:26:43 +08:00
renovate[bot] 31810f556c fix(deps): update module github.com/sirupsen/logrus to v1.9.4 (#2695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:25:27 +08:00
renovate[bot] 30922b6d52 fix(deps): update module github.com/pkg/sftp to v1.13.10 (#2694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:24:55 +08:00
renovate[bot] 759adfca21 fix(deps): update module github.com/charmbracelet/bubbles to v0.21.1 (#2686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 13:15:58 +08:00
Pikachu Ren bfbc441fa6 fix(drivers/189pc): sync .torrent file when copy/move/rename/upload (#2522)
* fix(torrent): sync .torrent file when copy/move/rename/upload

* fix(torrent): prevent recursive torrent generation

* fix(189pc): address review comments on torrent sidecar handling

- Only append SHA-1 piece hash when n > 0, preventing spurious piece
  for empty files (BitTorrent v1 requires 0 pieces for 0-byte files)
- Explicitly guard torrent generation with size > 0
- Log findFileByName errors in torrentFollowCopy/Move/Rename instead of
  silently discarding them
- Log WaitBatchTask failure for follow-move operation
- Rename Cloud189File.ParentId to ParentID to match Go initialism
  conventions (consistent with Cloud189Folder.ParentID, Cloud189File.ID)

* revert(ci): go .sum 115 sdk

---------

Co-authored-by: Suyunmeng <Susus0175@proton.me>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-01 11:35:45 +08:00
Yinan Qin 866793f485 fix(go.mod): update Go version to 1.25 (#2692)
Signed-off-by: elysia <a.elysia@proton.me>
2026-07-01 11:21:06 +08:00
renovate[bot] 40b39000ab fix(deps): update github.com/t3rm1n4l/go-mega digest to 6845944 (#2088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 10:01:24 +08:00
copilot-swe-agent[bot] 004a15bc5e chore: merge main into map branch
- Resolve content conflict in pkg/generic_sync/map.go by keeping simplified
  sync.Map wrapper and adding new utility methods (Count, Empty, ToMap, Clear)
  introduced in main
- Keep deletion of pkg/task/task.go and pkg/task/task_test.go
2026-06-22 09:05:11 +00:00
j2rong4cn 9ac2f3b7f0 refactor(task): remove task package and related files 2026-06-20 08:45:40 +08:00
j2rong4cn 756862d1e4 refactor(generic_sync): simplify MapOf implementation using sync.Map 2026-06-20 08:43:38 +08:00
51 changed files with 488 additions and 11285 deletions
-153
View File
@@ -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
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.0"
go-version: "1.26.4"
- name: Setup web
run: bash build.sh dev web
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.0"
go-version: "1.26.4"
- name: Setup web
run: bash build.sh dev web
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.25.0'
go-version: '1.26.4'
- name: Checkout
uses: actions/checkout@v6
+2 -2
View File
@@ -47,7 +47,7 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version: '1.25.0'
go-version: '1.26'
- name: Cache Musl
id: cache-musl
@@ -87,7 +87,7 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version: '1.25.0'
go-version: '1.26.4'
- name: Cache Musl
id: cache-musl
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version: '1.25.0'
go-version: '1.26.4'
- name: Cache Musl
id: cache-musl
+3 -3
View File
@@ -289,7 +289,7 @@ BuildRelease() {
BuildLoongGLIBC() {
local target_abi="$2"
local output_file="$1"
local oldWorldGoVersion="1.25.0"
local oldWorldGoVersion="1.26.4"
local loong_tags
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
@@ -309,13 +309,13 @@ BuildLoongGLIBC() {
# Download and setup patched Go compiler for old-world
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
echo "Error: Failed to download patched Go compiler for old-world ABI1.0"
if [ -n "$GITHUB_TOKEN" ]; then
echo "Error output from curl:"
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
fi
return 1
-565
View File
@@ -1,565 +0,0 @@
// Command openlist-transcode-worker 是独立的远程转码 Worker 节点。
// 它通过 OpenList 自研协议(HTTP/JSON)注册到 Master,长轮询领任务,调用
// 本机 ffmpeg 转码,并把生成的 HLS 切片实时上传回 Master。
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
var (
masterURL = flag.String("master", "http://127.0.0.1:5244", "OpenList Master URL")
secret = flag.String("secret", "", "Worker 共享密钥(与 Master 配置一致)")
name = flag.String("name", hostname(), "Worker 名称")
capacity = flag.Int("capacity", 2, "并发任务数")
hwaccel = flag.String("hwaccel", "none", "硬件加速:none/auto/nvenc/qsv/vaapi/amf/videotoolbox")
workdir = flag.String("workdir", "./worker_tmp", "临时工作目录")
ffmpeg = flag.String("ffmpeg", "ffmpeg", "ffmpeg 路径")
)
func hostname() string {
h, _ := os.Hostname()
if h == "" {
h = "worker"
}
return h
}
// ---- 协议结构 ----(与 Master 端 internal/transcode/protocol.go 一致)
type RegisterReq struct {
Name string `json:"name"`
Version string `json:"version"`
Capacity int `json:"capacity"`
HWAccel []string `json:"hwaccel"`
CodecsDecode []string `json:"codecs_decode"`
CodecsEncode []string `json:"codecs_encode"`
MaxResolution string `json:"max_resolution"`
Tags []string `json:"tags"`
}
type RegisterResp struct {
Data struct {
WorkerID string `json:"worker_id"`
HeartbeatInterval int `json:"heartbeat_interval"`
} `json:"data"`
}
type HeartbeatReq struct {
WorkerID string `json:"worker_id"`
Load float64 `json:"load"`
Running []string `json:"running"`
FreeSlots int `json:"free_slots"`
}
type HeartbeatResp struct {
Data struct {
OK bool `json:"ok"`
Kick bool `json:"kick"`
Cancel []string `json:"cancel"`
} `json:"data"`
}
type ClaimReq struct {
WorkerID string `json:"worker_id"`
Slots int `json:"slots"`
Wait int `json:"wait"`
}
type Profile struct {
Name string `json:"name"`
VideoCodec string `json:"video_codec"`
VideoBitrate string `json:"video_bitrate"`
Scale string `json:"scale"`
AudioCodec string `json:"audio_codec"`
AudioBitrate string `json:"audio_bitrate"`
HWAccel string `json:"hwaccel"`
}
type OutputSpec struct {
Format string `json:"format"`
SegmentDuration int `json:"segment_duration"`
}
type Job struct {
ID string `json:"id"`
Path string `json:"path"`
SourceURL string `json:"source_url"`
Profiles []Profile `json:"profiles"`
Output OutputSpec `json:"output"`
CallbackToken string `json:"callback_token"`
Deadline int64 `json:"deadline"`
}
type ClaimResp struct {
Data struct {
Jobs []Job `json:"jobs"`
} `json:"data"`
}
type FinishReq struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Stats struct {
Elapsed float64 `json:"elapsed"`
BytesOut int64 `json:"bytes_out"`
} `json:"stats"`
}
// ---- HTTP helper ----
var httpClient = &http.Client{Timeout: 60 * time.Second}
func postJSON(path string, body any, out any, bearer string) error {
buf, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, *masterURL+path, bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// ---- main ----
func main() {
flag.Parse()
if *secret == "" {
fmt.Fprintln(os.Stderr, "missing -secret")
os.Exit(1)
}
if err := os.MkdirAll(*workdir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
workerID, hbInterval, err := registerOnce()
if err != nil {
fmt.Fprintln(os.Stderr, "register failed:", err)
os.Exit(1)
}
fmt.Printf("[worker] registered: id=%s heartbeat=%ds\n", workerID, hbInterval)
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("[worker] shutting down")
cancel()
}()
state := &workerState{
id: workerID,
runningMu: sync.Mutex{},
running: map[string]context.CancelFunc{},
}
// 心跳协程
go func() {
t := time.NewTicker(time.Duration(hbInterval) * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
state.heartbeat()
}
}
}()
// 工作协程
for i := 0; i < *capacity; i++ {
go state.workLoop(ctx)
}
<-ctx.Done()
time.Sleep(500 * time.Millisecond)
}
func registerOnce() (string, int, error) {
req := RegisterReq{
Name: *name,
Version: "1.0.0",
Capacity: *capacity,
HWAccel: []string{*hwaccel},
CodecsDecode: []string{"h264", "hevc", "av1", "vp9", "mpeg4", "mpeg2video"},
CodecsEncode: []string{"h264", "hevc"},
MaxResolution: "3840x2160",
Tags: []string{runtime.GOOS, runtime.GOARCH},
}
var resp RegisterResp
if err := postJSON("/api/transcode/worker/register", req, &resp, *secret); err != nil {
return "", 0, err
}
if resp.Data.WorkerID == "" {
return "", 0, fmt.Errorf("master returned empty worker_id")
}
if resp.Data.HeartbeatInterval == 0 {
resp.Data.HeartbeatInterval = 10
}
return resp.Data.WorkerID, resp.Data.HeartbeatInterval, nil
}
// ---- 状态机 ----
type workerState struct {
id string
runningMu sync.Mutex
running map[string]context.CancelFunc
}
func (s *workerState) heartbeat() {
s.runningMu.Lock()
ids := make([]string, 0, len(s.running))
for id := range s.running {
ids = append(ids, id)
}
free := *capacity - len(ids)
s.runningMu.Unlock()
var resp HeartbeatResp
if err := postJSON("/api/transcode/worker/heartbeat", HeartbeatReq{
WorkerID: s.id,
Running: ids,
FreeSlots: free,
}, &resp, *secret); err != nil {
fmt.Println("[worker] heartbeat error:", err)
return
}
for _, jid := range resp.Data.Cancel {
s.runningMu.Lock()
if cancel, ok := s.running[jid]; ok {
cancel()
}
s.runningMu.Unlock()
}
}
func (s *workerState) workLoop(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
var resp ClaimResp
err := postJSON("/api/transcode/worker/claim", ClaimReq{
WorkerID: s.id, Slots: 1, Wait: 20,
}, &resp, *secret)
if err != nil {
fmt.Println("[worker] claim error:", err)
time.Sleep(5 * time.Second)
continue
}
if len(resp.Data.Jobs) == 0 {
continue
}
for _, j := range resp.Data.Jobs {
s.runJob(ctx, j)
}
}
}
func (s *workerState) runJob(parent context.Context, job Job) {
jobCtx, cancel := context.WithCancel(parent)
s.runningMu.Lock()
s.running[job.ID] = cancel
s.runningMu.Unlock()
defer func() {
s.runningMu.Lock()
delete(s.running, job.ID)
s.runningMu.Unlock()
cancel()
}()
startedAt := time.Now()
tmpDir := filepath.Join(*workdir, job.ID)
_ = os.MkdirAll(tmpDir, 0o755)
defer os.RemoveAll(tmpDir)
if len(job.Profiles) == 0 {
s.finish(job, "failed", "no profiles", 0, 0)
return
}
profile := job.Profiles[0]
playlist := filepath.Join(tmpDir, "playlist.m3u8")
segPattern := filepath.Join(tmpDir, "seg-%d.ts")
args := buildFFmpegArgs(job, profile, segPattern, playlist)
cmd := exec.CommandContext(jobCtx, *ffmpeg, args...)
stderr := &bytes.Buffer{}
cmd.Stderr = stderr
fmt.Printf("[worker] running job %s, ffmpeg %s\n", job.ID, strings.Join(args, " "))
if err := cmd.Start(); err != nil {
s.finish(job, "failed", err.Error(), 0, 0)
return
}
// 上传协程
stopUpload := make(chan struct{})
uploadDone := make(chan struct{})
go func() {
defer close(uploadDone)
s.watchAndUpload(jobCtx, job, profile.Name, tmpDir, stopUpload)
}()
err := cmd.Wait()
close(stopUpload)
<-uploadDone
// 最后一次扫描:把还未上传的尾片传上去
s.uploadOnce(job, profile.Name, tmpDir, true)
if err != nil {
if jobCtx.Err() == context.Canceled {
s.finish(job, "cancelled", "cancelled by master", 0, 0)
return
}
s.finish(job, "failed", fmt.Sprintf("%v: %s", err, truncate(stderr.String(), 1024)), 0, 0)
return
}
s.finish(job, "finished", "", time.Since(startedAt).Seconds(), 0)
}
func (s *workerState) finish(job Job, status, errStr string, elapsed float64, bytesOut int64) {
req := FinishReq{
JobID: job.ID, Status: status, Error: errStr,
}
req.Stats.Elapsed = elapsed
req.Stats.BytesOut = bytesOut
if err := postJSON("/api/transcode/worker/job/finish", req, nil, job.CallbackToken); err != nil {
fmt.Printf("[worker] finish report failed: %v\n", err)
}
}
func (s *workerState) watchAndUpload(_ context.Context, job Job, profile, dir string, stop chan struct{}) {
t := time.NewTicker(800 * time.Millisecond)
defer t.Stop()
uploaded := map[int]bool{}
flush := func(final bool) {
s.uploadDelta(job, profile, dir, uploaded, final)
}
for {
select {
case <-stop:
flush(false)
return
case <-t.C:
flush(false)
}
}
}
func (s *workerState) uploadOnce(job Job, profile, dir string, final bool) {
uploaded := map[int]bool{}
s.uploadDelta(job, profile, dir, uploaded, final)
}
// uploadDelta 扫描目录,上传新切片(uploaded 跟踪已传过的 seq;最后一片到 final 时才上传)
func (s *workerState) uploadDelta(job Job, profile, dir string, uploaded map[int]bool, final bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type segFile struct {
seq int
path string
}
var segs []segFile
for _, e := range entries {
if e.IsDir() {
continue
}
n := e.Name()
if !strings.HasPrefix(n, "seg-") || !strings.HasSuffix(n, ".ts") {
continue
}
num, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(n, "seg-"), ".ts"))
if err != nil {
continue
}
segs = append(segs, segFile{num, filepath.Join(dir, n)})
}
// 排序
for i := 0; i < len(segs); i++ {
for j := i + 1; j < len(segs); j++ {
if segs[j].seq < segs[i].seq {
segs[i], segs[j] = segs[j], segs[i]
}
}
}
limit := len(segs)
if !final && limit > 0 {
limit-- // 最后一片可能还在写,留到下一轮
}
segDur := job.Output.SegmentDuration
if segDur == 0 {
segDur = 6
}
for i := 0; i < limit; i++ {
seg := segs[i]
if uploaded[seg.seq] {
continue
}
isLast := final && i == limit-1
if err := s.uploadSegment(job, profile, seg.seq, float64(segDur), seg.path, isLast); err != nil {
fmt.Printf("[worker] upload seg-%d failed: %v\n", seg.seq, err)
return
}
uploaded[seg.seq] = true
}
}
func (s *workerState) uploadSegment(job Job, profile string, seq int, dur float64, file string, final bool) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
q := fmt.Sprintf("?job=%s&profile=%s&seq=%d&duration=%f&final=%t",
job.ID, profile, seq, dur, final)
req, _ := http.NewRequest(http.MethodPut, *masterURL+"/api/transcode/worker/segment"+q, f)
req.Header.Set("Content-Type", "video/mp2t")
req.Header.Set("Authorization", "Bearer "+job.CallbackToken)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
}
return nil
}
// ---- ffmpeg args(与 master 端 local_runner 保持一致)----
func buildFFmpegArgs(job Job, profile Profile, segPattern, playlist string) []string {
hw := strings.ToLower(profile.HWAccel)
args := []string{"-y", "-loglevel", "warning"}
switch hw {
case "nvenc":
args = append(args, "-hwaccel", "cuda")
case "qsv":
args = append(args, "-hwaccel", "qsv")
case "vaapi":
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", "/dev/dri/renderD128")
case "amf":
args = append(args, "-hwaccel", "d3d11va")
case "videotoolbox":
args = append(args, "-hwaccel", "videotoolbox")
case "auto":
args = append(args, "-hwaccel", "auto")
}
args = append(args, "-i", job.SourceURL)
args = append(args, "-c:v", pickEncoder(profile.VideoCodec, hw))
if profile.VideoBitrate != "" {
args = append(args, "-b:v", profile.VideoBitrate, "-maxrate", profile.VideoBitrate, "-bufsize", profile.VideoBitrate)
}
if profile.Scale != "" {
switch hw {
case "vaapi":
args = append(args, "-vf", "scale_vaapi="+profile.Scale)
case "qsv":
args = append(args, "-vf", "scale_qsv="+profile.Scale)
default:
args = append(args, "-vf", "scale="+profile.Scale)
}
}
if profile.AudioCodec == "copy" {
args = append(args, "-c:a", "copy")
} else {
ac := profile.AudioCodec
if ac == "" {
ac = "aac"
}
args = append(args, "-c:a", ac)
if profile.AudioBitrate != "" {
args = append(args, "-b:a", profile.AudioBitrate)
}
}
segDur := job.Output.SegmentDuration
if segDur == 0 {
segDur = 6
}
args = append(args,
"-f", "hls",
"-hls_time", strconv.Itoa(segDur),
"-hls_playlist_type", "vod",
"-hls_segment_filename", segPattern,
"-hls_flags", "independent_segments",
playlist,
)
return args
}
func pickEncoder(codec, hw string) string {
codec = strings.ToLower(codec)
switch codec {
case "h264", "":
switch hw {
case "nvenc":
return "h264_nvenc"
case "qsv":
return "h264_qsv"
case "vaapi":
return "h264_vaapi"
case "amf":
return "h264_amf"
case "videotoolbox":
return "h264_videotoolbox"
}
return "libx264"
case "hevc", "h265":
switch hw {
case "nvenc":
return "hevc_nvenc"
case "qsv":
return "hevc_qsv"
case "vaapi":
return "hevc_vaapi"
case "amf":
return "hevc_amf"
case "videotoolbox":
return "hevc_videotoolbox"
}
return "libx265"
case "av1":
switch hw {
case "nvenc":
return "av1_nvenc"
case "qsv":
return "av1_qsv"
}
return "libsvtav1"
}
return "libx264"
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
-167
View File
@@ -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
View File
@@ -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 {
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
}
@@ -298,6 +308,12 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin
}
return nil, err
}
// 跟随重命名 torrent 文件
if f, ok := srcObj.(*Cloud189File); ok {
y.torrentFollowRename(f.ParentID, srcObj.GetName(), newName)
}
switch f := srcObj.(type) {
case *Cloud189File:
return resp.toFile(f), nil
@@ -319,7 +335,20 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
if err != nil {
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 {
+158
View File
@@ -1,6 +1,7 @@
package _189pc
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
@@ -8,10 +9,13 @@ import (
"io"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
"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/utils"
)
@@ -162,6 +166,36 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
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
}
@@ -258,6 +292,10 @@ func GetInfoHashHex(torrentData []byte) (string, error) {
return hex.EncodeToString(t.InfoHash), nil
}
func isCASTorrentFile(fileName string) bool {
return strings.HasSuffix(fileName, ".cas.torrent")
}
// ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5
// 返回:整文件 MD5、分片 MD5 列表
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)))
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)
}
}()
}
+5 -4
View File
@@ -166,10 +166,11 @@ type FamilyInfoResp struct {
/*文件部分*/
// 文件
type Cloud189File struct {
ID String `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Md5 string `json:"md5"`
ID String `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Md5 string `json:"md5"`
ParentID string `json:"-"` // 由 getFiles 设置,不从 JSON 解析
LastOpTime Time `json:"lastOpTime"`
CreateDate Time `json:"createDate"`
+63 -1
View File
@@ -210,6 +210,7 @@ func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool)
res = append(res, &resp.FileListAO.FolderList[i])
}
for i := 0; i < len(resp.FileListAO.FileList); i++ {
resp.FileListAO.FileList[i].ParentID = fileId
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) {
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 (
cache = file.GetFile()
tmpF *os.File
@@ -947,6 +953,9 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
if tmpF != nil {
writers = append(writers, tmpF)
}
pieceSHA1Hashes := make([]byte, 0, count*20)
written := int64(0)
for i := 1; i <= count; i++ {
if utils.IsCanceled(ctx) {
@@ -957,7 +966,17 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
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
if err != nil && err != io.EOF {
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)))
partInfos = append(partInfos, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte)))
sliceMd5.Reset()
// 收集 SHA-1 piece hash(仅在本次分片实际写入了数据时追加)
if generateTorrent && n > 0 {
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Writer.Sum(nil)...)
}
}
if tmpF != nil {
@@ -1081,6 +1105,44 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
if err != nil {
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
}
+57 -51
View File
@@ -1,8 +1,8 @@
module github.com/OpenListTeam/OpenList/v4
go 1.24.0
go 1.25.0
toolchain go1.24.13
toolchain go1.26.4
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1
@@ -25,8 +25,8 @@ require (
github.com/blevesearch/bleve/v2 v2.5.2
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/caarlos0/env/v9 v9.0.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/bubbles v0.21.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc
@@ -39,8 +39,8 @@ require (
github.com/fclairamb/ftpserverlib v0.26.1-0.20250709223522-4a925d79caf6
github.com/foxxorcat/mopan-sdk-go v0.1.6
github.com/foxxorcat/weiyun-sdk-go v0.1.4
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-resty/resty/v2 v2.16.5
github.com/go-webauthn/webauthn v0.13.4
@@ -57,37 +57,36 @@ require (
github.com/kdomanski/iso9660 v0.4.0
github.com/maruel/natural v1.1.1
github.com/meilisearch/meilisearch-go v0.32.0
github.com/mholt/archives v0.1.3
github.com/mholt/archives v0.1.5
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/ncw/swift/v2 v2.0.4
github.com/ncw/swift/v2 v2.0.5
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.9
github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0
github.com/quic-go/quic-go v0.54.1
github.com/quic-go/quic-go v0.59.0
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/sirupsen/logrus v1.9.4
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c
github.com/tchap/go-patricia/v2 v2.3.3
github.com/u2takey/ffmpeg-go v0.5.0
github.com/upyun/go-sdk/v3 v3.0.4
github.com/winfsp/cgofuse v1.6.0
github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3
golang.org/x/crypto v0.46.0
golang.org/x/crypto v0.50.0
golang.org/x/image v0.29.0
golang.org/x/net v0.48.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.34.0
golang.org/x/time v0.14.0
google.golang.org/appengine v1.6.8
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.6.0
gorm.io/gorm v1.30.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
)
require (
@@ -101,8 +100,12 @@ require (
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
@@ -112,6 +115,7 @@ require (
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
@@ -121,18 +125,19 @@ 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/minlz v1.0.1 // indirect
github.com/minio/xxml v0.0.3 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stangelandcl/ppmd v0.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/mock v0.5.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/mod v0.35.0 // indirect
gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
@@ -142,18 +147,18 @@ require (
require (
github.com/OpenListTeam/115-sdk-go v0.2.4
github.com/STARRY-S/zip v0.2.1 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1
github.com/bodgit/sevenzip v1.6.4
github.com/bodgit/windows v1.0.1 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/charmbracelet/x/ansi v0.11.5 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fclairamb/go-log v0.6.0 // indirect
@@ -166,10 +171,10 @@ require (
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode/v2 v2.1.1
github.com/sorairolake/lzip-go v0.3.5 // indirect
github.com/nwaples/rardecode/v2 v2.2.0
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f
resty.dev/v3 v3.0.0-beta.2 // indirect
)
@@ -180,12 +185,12 @@ require (
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/aead/ecdh v0.2.0 // indirect
github.com/andreburgaud/crypt2go v1.8.0 // indirect
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
github.com/blevesearch/geo v0.2.3 // indirect
@@ -203,20 +208,20 @@ require (
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/crackcomm/go-gitignore v0.0.0-20170627025303-887ab5e44cc3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-webauthn/x v0.1.23 // indirect
github.com/goccy/go-json v0.10.5 // indirect
@@ -238,20 +243,21 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
github.com/libp2p/go-flow-metrics v0.1.0 // indirect
github.com/libp2p/go-libp2p v0.27.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
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-runewidth v0.0.19 // 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
@@ -271,7 +277,7 @@ require (
github.com/multiformats/go-multistream v0.4.1 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/cachecontrol v0.1.0 // indirect
@@ -290,16 +296,16 @@ require (
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u2takey/go-utils v0.3.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0
golang.org/x/tools v0.39.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.37.0
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11 // indirect
@@ -315,4 +321,4 @@ replace github.com/cronokirby/saferith => github.com/Da3zKi7/saferith v0.33.0-fi
// replace github.com/OpenListTeam/115-sdk-go => ../../OpenListTeam/115-sdk-go
replace github.com/KarpelesLab/reflink => github.com/OpenListTeam/reflink v0.0.0-20260520031008-ed3c0dbe8009
replace github.com/KarpelesLab/reflink => github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef
+112 -8
View File
@@ -35,6 +35,8 @@ github.com/OpenListTeam/gsync v0.1.0 h1:ywzGybOvA3lW8K1BUjKZ2IUlT2FSlzPO4DOazfYX
github.com/OpenListTeam/gsync v0.1.0/go.mod h1:h/Rvv9aX/6CdW/7B8di3xK3xNV8dUg45Fehrd/ksZ9s=
github.com/OpenListTeam/reflink v0.0.0-20260520031008-ed3c0dbe8009 h1:qLqJPr/FAsZTJiqy65JKKuFJP0V9pRVtSaIE0kqaQ8w=
github.com/OpenListTeam/reflink v0.0.0-20260520031008-ed3c0dbe8009/go.mod h1:WGkTOKNjd1FsJKBw3mu4JvrPEDJyJJ+JPtxBkbPoCok=
github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef h1:67uGHancMF/abMrnkc8abVUWQiG73Wk5d8CKt3RzkFo=
github.com/OpenListTeam/reflink v0.0.0-20260701021214-78760eaeafef/go.mod h1:WGkTOKNjd1FsJKBw3mu4JvrPEDJyJJ+JPtxBkbPoCok=
github.com/OpenListTeam/sftpd-openlist v1.0.1 h1:j4S3iPFOpnXCUKRPS7uCT4mF2VCl34GyqvH6lqwnkUU=
github.com/OpenListTeam/sftpd-openlist v1.0.1/go.mod h1:uO/wKnbvbdq3rBLmClMTZXuCnw7XW4wlAq4dZe91a40=
github.com/OpenListTeam/tache v0.2.2 h1:CWFn6sr1AIYaEjC8ONdKs+LrxHyuErheenAjEqRhh4k=
@@ -63,6 +65,8 @@ github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg=
github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/SheltonZhu/115driver v1.3.3 h1:Bqs86D2MziYPgIOuOJF+HzG4d7GBr71ZhSCrs/U17UU=
github.com/SheltonZhu/115driver v1.3.3/go.mod h1:OujS7azslg1/bn85sPSHnNsp4/WBI9/TiijtZL9kuSQ=
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
@@ -76,6 +80,10 @@ github.com/andreburgaud/crypt2go v1.8.0/go.mod h1:L5nfShQ91W78hOWhUH2tlGRPO+POAP
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ=
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
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.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
@@ -128,6 +136,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/blevesearch/bleve/v2 v2.5.2 h1:Ab0r0MODV2C5A6BEL87GqLBySqp/s9xFgceCju6BQk8=
@@ -172,6 +182,8 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
@@ -179,35 +191,59 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo=
github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc=
github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764=
github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e h1:GLC8iDDcbt1H8+RkNao2nRGjyNTIo81e1rAJT9/uWYA=
github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e/go.mod h1:ln9Whp+wVY/FTbn2SK0ag+SKD2fC0yQCF/Lqowc1LmU=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
@@ -217,6 +253,8 @@ github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqL
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0=
github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
@@ -273,14 +311,20 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -309,6 +353,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
@@ -319,6 +365,8 @@ github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -439,10 +487,14 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
@@ -466,6 +518,8 @@ github.com/libp2p/go-libp2p v0.27.8 h1:IX5x/4yKwyPQeVS2AXHZ3J4YATM9oHBGH1gBc23jB
github.com/libp2p/go-libp2p v0.27.8/go.mod h1:eCFFtd0s5i/EVKR7+5Ki8bM7qwkNW3TPTTSSW9sz8NE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
@@ -482,18 +536,24 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/meilisearch/meilisearch-go v0.32.0 h1:cWcycpONSH3VLTZ5npUl1O5aXPkNM0vUx6bywnYqGbE=
github.com/meilisearch/meilisearch-go v0.32.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g=
github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458=
github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ=
github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/minio/xxml v0.0.3 h1:ZIpPQpfyG5uZQnqqC0LZuWtPk/WT8G/qkxvO6jb7zMU=
@@ -541,13 +601,19 @@ github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/ncw/swift/v2 v2.0.4 h1:hHWVFxn5/YaTWAASmn4qyq2p6OyP/Hm3vMLzkjEqR7w=
github.com/ncw/swift/v2 v2.0.4/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk=
github.com/ncw/swift/v2 v2.0.5 h1:9o5Gsd7bInAFEqsGPcaUdsboMbqf8lnNtxqWKFT9iz8=
github.com/ncw/swift/v2 v2.0.5/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk=
github.com/nwaples/rardecode/v2 v2.1.1 h1:OJaYalXdliBUXPmC8CZGQ7oZDxzX1/5mQmgn0/GASew=
github.com/nwaples/rardecode/v2 v2.1.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -555,6 +621,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA=
github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -576,8 +644,12 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rclone/rclone v1.70.3 h1:rg/WNh4DmSVZyKP2tHZ4lAaWEyMi7h/F0r7smOMA3IE=
github.com/rclone/rclone v1.70.3/go.mod h1:nLyN+hpxAsQn9Rgt5kM774lcRDad82x/KqQeBZ83cMo=
github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU=
@@ -593,8 +665,6 @@ 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=
@@ -605,12 +675,16 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg=
github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
@@ -621,6 +695,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stangelandcl/ppmd v0.1.0 h1:0gOKtSdWyXxpRW55H/dZ3AgaJqjrtlIrAvsokFwp7ug=
github.com/stangelandcl/ppmd v0.1.0/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -635,10 +711,12 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 h1:Sa+sR8aaAMFwxhXWENEnE6ZpqhZ9d7u1RT2722Rw6hc=
github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5/go.mod h1:UdZiFUFu6e2WjjtjxivwXWcwc1N/8zgbkBR9QNucUOY=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c h1:dtcOwRimeiBFrlutmF6K94l0rxYFARNFMA+lSQ41C+M=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo=
github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 h1:6Y51mutOvRGRx6KqyMNo//xk8B8o6zW9/RVmy1VamOs=
github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543/go.mod h1:jpwqYA8KUVEvSUJHkCXsnBRJCSKP1BMa81QZ6kvRpow=
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
@@ -655,9 +733,13 @@ github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4y
github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U=
github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8=
github.com/upyun/go-sdk/v3 v3.0.4 h1:2DCJa/Yi7/3ZybT9UCPATSzvU3wpPPxhXinNlb1Hi8Q=
@@ -677,6 +759,9 @@ github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3 h1:PSRw
github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3/go.mod h1:CKriYB8bkNgSbYUQF1khSpejKb5IsV6cR7MdaAR7Fc0=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -695,12 +780,15 @@ go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -713,6 +801,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -725,6 +815,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -741,6 +833,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -752,6 +846,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -776,6 +872,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -788,6 +886,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -802,6 +902,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
@@ -815,6 +917,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
@@ -855,11 +959,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.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
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/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
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,79 +249,6 @@ 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.Warn // Warn 级别:只输出慢查询和错误,不输出每条 SQL
logLevel = logger.Info
}
newLogger := logger.New(
stdlog.New(log.StandardLogger().Out, "\r\n", stdlog.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond, // 超过 200ms 才记录慢查询
SlowThreshold: time.Second,
LogLevel: logLevel,
IgnoreRecordNotFoundError: true,
Colorful: true,
-5
View File
@@ -15,7 +15,6 @@ 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"
@@ -93,10 +92,6 @@ 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,45 +161,6 @@ 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 (
+1 -39
View File
@@ -12,50 +12,12 @@ var db *gorm.DB
func Init(d *gorm.DB) {
db = d
// 迁移前处理:处理 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))
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))
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" {
-563
View File
@@ -1,563 +0,0 @@
package db
import (
"encoding/json"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"gorm.io/gorm"
)
// ==================== MediaConfig ====================
// GetMediaConfig 获取指定类型的媒体库配置,不存在则返回默认值
func GetMediaConfig(mediaType model.MediaType) (*model.MediaConfig, error) {
var cfg model.MediaConfig
result := db.Where("media_type = ?", mediaType).First(&cfg)
if result.Error == gorm.ErrRecordNotFound {
return &model.MediaConfig{
MediaType: mediaType,
Enabled: false,
}, nil
}
return &cfg, result.Error
}
// GetAllMediaConfigs 获取所有媒体库配置
func GetAllMediaConfigs() ([]model.MediaConfig, error) {
var cfgs []model.MediaConfig
err := db.Find(&cfgs).Error
return cfgs, err
}
// SaveMediaConfig 保存媒体库配置(upsert)
func SaveMediaConfig(cfg *model.MediaConfig) error {
var existing model.MediaConfig
result := db.Where("media_type = ?", cfg.MediaType).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return db.Create(cfg).Error
}
cfg.ID = existing.ID
return db.Save(cfg).Error
}
// ==================== MediaScanPath ====================
// ListMediaScanPaths 获取指定媒体类型的所有扫描路径
func ListMediaScanPaths(mediaType model.MediaType) ([]model.MediaScanPath, error) {
var paths []model.MediaScanPath
tx := db.Model(&model.MediaScanPath{})
if mediaType != "" {
tx = tx.Where("media_type = ?", mediaType)
}
err := tx.Order("id asc").Find(&paths).Error
return paths, err
}
// GetMediaScanPath 按ID获取扫描路径
func GetMediaScanPath(id uint) (*model.MediaScanPath, error) {
var p model.MediaScanPath
err := db.First(&p, id).Error
return &p, err
}
// CreateMediaScanPath 创建扫描路径
func CreateMediaScanPath(p *model.MediaScanPath) error {
return db.Create(p).Error
}
// UpdateMediaScanPath 更新扫描路径
func UpdateMediaScanPath(p *model.MediaScanPath) error {
return db.Save(p).Error
}
// DeleteMediaScanPath 删除扫描路径(硬删除)
func DeleteMediaScanPath(id uint) error {
return db.Unscoped().Delete(&model.MediaScanPath{}, id).Error
}
// ==================== MediaItem ====================
// MediaItemQuery 媒体条目查询参数
type MediaItemQuery struct {
MediaType model.MediaType
ScanPathID uint // 按扫描路径ID筛选
FolderPath string // 按文件夹路径筛选
TypeTag string // 按类型标签筛选(电影、电视剧等)
ContentTag string // 按内容标签筛选(喜剧、惊悚等)
Hidden *bool
Keyword string
OrderBy string // "name", "date", "size"
OrderDir string // "asc", "desc"
Page int
PageSize int
}
// ListMediaItems 分页查询媒体条目
func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) {
tx := db.Model(&model.MediaItem{})
if q.MediaType != "" {
tx = tx.Where("media_type = ?", q.MediaType)
}
if q.ScanPathID > 0 {
tx = tx.Where("scan_path_id = ?", q.ScanPathID)
}
if q.FolderPath != "" {
tx = tx.Where("folder_path = ?", q.FolderPath)
}
if q.Hidden != nil {
tx = tx.Where("hidden = ?", *q.Hidden)
}
if q.Keyword != "" {
like := "%" + q.Keyword + "%"
tx = tx.Where("file_name LIKE ? OR scraped_name LIKE ?", like, like)
}
// 按类型标签筛选(通过关联扫描路径的type_tag)
if q.TypeTag != "" {
tx = tx.Joins("JOIN media_scan_paths ON media_scan_paths.id = media_items.scan_path_id").
Where("media_scan_paths.type_tag = ?", q.TypeTag)
}
// 按内容标签筛选(通过关联扫描路径的content_tags)
if q.ContentTag != "" {
tx = tx.Joins("JOIN media_scan_paths ON media_scan_paths.id = media_items.scan_path_id").
Where("media_scan_paths.content_tags LIKE ?", "%"+q.ContentTag+"%")
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
// 排序
orderCol := "created_at"
switch q.OrderBy {
case "name":
orderCol = "COALESCE(NULLIF(scraped_name,''), file_name)"
case "date":
orderCol = "release_date"
case "size":
orderCol = "file_size"
}
dir := "asc"
if q.OrderDir == "desc" {
dir = "desc"
}
tx = tx.Order(orderCol + " " + dir)
// 分页
if q.PageSize <= 0 {
q.PageSize = 20
}
if q.Page <= 0 {
q.Page = 1
}
tx = tx.Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)
var items []model.MediaItem
err := tx.Find(&items).Error
return items, total, err
}
// GetMediaItemByID 按ID获取媒体条目
func GetMediaItemByID(id uint) (*model.MediaItem, error) {
var item model.MediaItem
err := db.First(&item, id).Error
return &item, err
}
// GetMediaItemByFolderPath 按文件夹路径获取媒体条目(用于合并模式)
func GetMediaItemByFolderPath(folderPath string) (*model.MediaItem, error) {
var item model.MediaItem
result := db.Where("folder_path = ?", folderPath).First(&item)
return &item, result.Error
}
// CreateOrUpdateMediaItem 创建或更新媒体条目(按 folder_path + file_name + album_name 组合唯一)
// 更新时保留已有的刮削数据,避免重新扫描时把已刮削的字段清空
//
// 注意:使用 Unscoped() 查询是为了把软删除残留记录也包含进来,
// 否则唯一索引 idx_media_folder_file_album 会与软删除行冲突,
// 导致新建条目时报 UNIQUE constraint failed。
func CreateOrUpdateMediaItem(item *model.MediaItem) error {
var existing model.MediaItem
result := db.Unscoped().Where("folder_path = ? AND file_name = ? AND album_name = ?", item.FolderPath, item.FileName, item.AlbumName).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return db.Create(item).Error
}
if result.Error != nil {
return result.Error
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
// 复用已有记录时清除软删除标记,确保该条目“恢复”为正常记录
item.DeletedAt = gorm.DeletedAt{}
// 如果已有刮削数据,保留刮削字段,防止重新扫描时覆盖刮削结果
if existing.ScrapedAt != nil {
item.ScrapedAt = existing.ScrapedAt
item.ScrapedName = existing.ScrapedName
item.Cover = existing.Cover
item.AlbumName = existing.AlbumName
item.AlbumArtist = existing.AlbumArtist
item.TrackNumber = existing.TrackNumber
item.Duration = existing.Duration
item.Genre = existing.Genre
item.ReleaseDate = existing.ReleaseDate
item.Rating = existing.Rating
item.Plot = existing.Plot
item.Authors = existing.Authors
item.Description = existing.Description
item.Publisher = existing.Publisher
item.ISBN = existing.ISBN
item.ExternalID = existing.ExternalID
}
return db.Unscoped().Save(item).Error
}
// UpdateMediaItem 更新媒体条目
// 使用 Unscoped 确保即使存在软删除标记也能正确更新(避免命中唯一索引但行不可见的诡异情况)。
func UpdateMediaItem(item *model.MediaItem) error {
return db.Unscoped().Save(item).Error
}
// DeleteMediaItem 硬删除媒体条目(真正从数据库删除)
func DeleteMediaItem(id uint) error {
return db.Unscoped().Delete(&model.MediaItem{}, id).Error
}
// ClearMediaItems 硬删除指定类型的所有媒体条目(真正从数据库删除)
func ClearMediaItems(mediaType model.MediaType) error {
return db.Unscoped().Where("media_type = ?", mediaType).Delete(&model.MediaItem{}).Error
}
// ClearMediaItemsByScanPath 硬删除指定扫描路径的所有媒体条目
func ClearMediaItemsByScanPath(scanPathID uint) error {
return db.Unscoped().Where("scan_path_id = ?", scanPathID).Delete(&model.MediaItem{}).Error
}
// ClearMediaScrapedData 清空指定类型的所有刮削数据(保留扫描出的文件记录本身)
// 仅清空刮削结果相关字段,不删除条目,便于重新刮削。
// mediaType 为空时表示对所有类型生效。
func ClearMediaScrapedData(mediaType model.MediaType) (int64, error) {
tx := db.Unscoped().Model(&model.MediaItem{})
if mediaType != "" {
tx = tx.Where("media_type = ?", mediaType)
}
updates := map[string]interface{}{
"scraped_name": "",
"description": "",
"cover": "",
"release_date": "",
"rating": 0,
"genre": "",
"authors": "",
"plot": "",
"reviews": "",
"external_id": "",
"album_artist": "",
"publisher": "",
"isbn": "",
"lyrics": "",
"scraped_at": nil,
}
result := tx.Updates(updates)
return result.RowsAffected, result.Error
}
// ListAllValidMediaItemsForCheck 列出指定类型下所有未删除的媒体条目(仅取必要字段,用于失效检查)
// mediaType 为空时返回所有类型条目。
func ListAllValidMediaItemsForCheck(mediaType model.MediaType) ([]model.MediaItem, error) {
var items []model.MediaItem
tx := db.Model(&model.MediaItem{}).
Select("id, media_type, scan_path_id, file_name, folder_path, is_folder")
if mediaType != "" {
tx = tx.Where("media_type = ?", mediaType)
}
err := tx.Find(&items).Error
return items, err
}
// DeleteMediaItemsByIDs 按ID列表硬删除媒体条目
func DeleteMediaItemsByIDs(ids []uint) error {
if len(ids) == 0 {
return nil
}
return db.Unscoped().Where("id IN ?", ids).Delete(&model.MediaItem{}).Error
}
// ListAlbums 列出所有专辑(音乐专用)
func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
type albumRow struct {
AlbumName string
AlbumArtist string
Cover string
ReleaseDate string
TrackCount int
ScanPathID uint
}
// 构建基础查询
baseQuery := db.Model(&model.MediaItem{}).
Where("media_type = ?", model.MediaTypeMusic)
if q.Hidden != nil {
baseQuery = baseQuery.Where("hidden = ?", *q.Hidden)
}
if q.ScanPathID > 0 {
baseQuery = baseQuery.Where("scan_path_id = ?", q.ScanPathID)
}
if q.Keyword != "" {
like := "%" + q.Keyword + "%"
baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ? OR scraped_name LIKE ?", like, like, like)
}
// 统计分组数(用子查询)
var total int64
if err := db.Table("(?) as sub", baseQuery.
Select("album_name, album_artist").
Group("album_name, album_artist")).
Count(&total).Error; err != nil {
return nil, 0, err
}
if q.PageSize <= 0 {
q.PageSize = 20
}
if q.Page <= 0 {
q.Page = 1
}
tx := baseQuery.
Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count, MAX(scan_path_id) as scan_path_id").
Group("album_name, album_artist").
Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)
var rows []albumRow
if err := tx.Scan(&rows).Error; err != nil {
return nil, 0, err
}
albums := make([]AlbumInfo, len(rows))
for i, r := range rows {
albums[i] = AlbumInfo{
AlbumName: r.AlbumName,
AlbumArtist: r.AlbumArtist,
Cover: r.Cover,
ReleaseDate: r.ReleaseDate,
TrackCount: r.TrackCount,
ScanPathID: r.ScanPathID,
}
}
return albums, total, nil
}
// AlbumInfo 专辑信息
type AlbumInfo struct {
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
Cover string `json:"cover"`
ReleaseDate string `json:"release_date"`
TrackCount int `json:"track_count"`
ScanPathID uint `json:"scan_path_id"`
}
// GetAlbumTracks 获取专辑曲目列表
// 支持两种模式:
// 1. 普通模式(is_folder=false):直接返回独立文件记录
// 2. 合并文件夹模式(is_folder=true):把 episodes 展开成虚拟 MediaItem 列表
// 展开后每条记录的 folder_path = 原folder_path/file_name(文件夹实际路径),file_name = episode.FileName
func GetAlbumTracks(albumName, albumArtist string) ([]model.MediaItem, error) {
var items []model.MediaItem
tx := db.Where("media_type = ?", model.MediaTypeMusic)
if albumName != "" {
tx = tx.Where("album_name = ?", albumName)
} else {
tx = tx.Where("(album_name = '' OR album_name IS NULL)")
}
if albumArtist != "" {
tx = tx.Where("album_artist = ?", albumArtist)
}
err := tx.Order("track_number asc").Find(&items).Error
if err != nil {
return nil, err
}
// 展开合并文件夹条目的 episodes
var result []model.MediaItem
for _, item := range items {
if !item.IsFolder || item.Episodes == "" {
result = append(result, item)
continue
}
// 解析 episodes
type EpisodeInfo struct {
FileName string `json:"file_name"`
Index int `json:"index"`
Title string `json:"title"`
}
var eps []EpisodeInfo
if err := json.Unmarshal([]byte(item.Episodes), &eps); err != nil || len(eps) == 0 {
// 解析失败则跳过该条目(不返回文件夹本身,避免播放路径错误)
continue
}
// 文件夹实际路径 = folder_path + "/" + file_name
actualDir := strings.TrimRight(item.FolderPath, "/") + "/" + item.FileName
for _, ep := range eps {
track := item // 复制基础信息(封面、专辑名、艺术家等)
track.ID = 0
track.IsFolder = false
track.FolderPath = actualDir
track.FileName = ep.FileName
track.TrackNumber = ep.Index
track.ScrapedName = ep.Title
track.Episodes = ""
result = append(result, track)
}
}
return result, nil
}
// ListFolderPaths 列出指定媒体类型下的所有文件夹路径(目录浏览模式)
func ListFolderPaths(mediaType model.MediaType) ([]string, error) {
var paths []string
err := db.Model(&model.MediaItem{}).
Where("media_type = ?", mediaType).
Distinct("folder_path").
Pluck("folder_path", &paths).Error
return paths, err
}
// GetUnscrappedItems 获取未刮削或刮削不完整的媒体条目
// 只要 scraped_at 为空,或 cover/scraped_name/plot 任一为空,就需要重新刮削
// limit <= 0 表示不限制数量,返回所有未刮削条目
func GetUnscrappedItems(mediaType model.MediaType, limit int) ([]model.MediaItem, error) {
var items []model.MediaItem
tx := db.Where(
"media_type = ? AND (scraped_at IS NULL OR cover = '' OR cover IS NULL OR scraped_name = '' OR scraped_name IS NULL OR plot = '' OR plot IS NULL)",
mediaType,
)
if limit > 0 {
tx = tx.Limit(limit)
}
err := tx.Find(&items).Error
return items, err
}
// ==================== 导入/导出 ====================
// ListAllMediaItems 列出指定媒体类型下的所有条目(不分页,用于导出)
// mediaType 为空时返回全部类型的所有条目
func ListAllMediaItems(mediaType model.MediaType) ([]model.MediaItem, error) {
var items []model.MediaItem
tx := db.Model(&model.MediaItem{})
if mediaType != "" {
tx = tx.Where("media_type = ?", mediaType)
}
err := tx.Order("id asc").Find(&items).Error
return items, err
}
// ListMediaItemsByScanPath 列出指定扫描路径下的所有条目(不分页,用于导出)
func ListMediaItemsByScanPath(scanPathID uint) ([]model.MediaItem, error) {
var items []model.MediaItem
err := db.Where("scan_path_id = ?", scanPathID).Order("id asc").Find(&items).Error
return items, err
}
// ListAllMediaScanPaths 列出所有扫描路径(不区分类型,用于全量导出)
func ListAllMediaScanPaths() ([]model.MediaScanPath, error) {
var paths []model.MediaScanPath
err := db.Order("id asc").Find(&paths).Error
return paths, err
}
// ImportMediaItems 批量导入媒体条目
// 策略:按 (folder_path, file_name, album_name) 唯一键 upsert:
// - 已存在则覆盖刮削字段(导入是用户主动行为,覆盖优先于保留)
// - 不存在则新建
//
// 注意:导入数据中的 ID/CreatedAt/UpdatedAt 会被忽略,由数据库重新分配,
// 避免和现有记录的主键冲突。
func ImportMediaItems(items []model.MediaItem, scanPathIDOverride *uint) (created, updated int, err error) {
for i := range items {
it := items[i]
// 清空主键和时间戳,让数据库重新生成
it.ID = 0
it.CreatedAt = time.Time{}
it.UpdatedAt = time.Time{}
it.DeletedAt = gorm.DeletedAt{}
// 如果指定了覆盖的 scan_path_id(按扫描路径导入场景),则强制覆盖
if scanPathIDOverride != nil {
it.ScanPathID = *scanPathIDOverride
}
var existing model.MediaItem
result := db.Unscoped().Where(
"folder_path = ? AND file_name = ? AND album_name = ?",
it.FolderPath, it.FileName, it.AlbumName,
).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
if e := db.Create(&it).Error; e != nil {
return created, updated, e
}
created++
continue
}
if result.Error != nil {
return created, updated, result.Error
}
// 覆盖更新
it.ID = existing.ID
it.CreatedAt = existing.CreatedAt
it.DeletedAt = gorm.DeletedAt{}
if e := db.Unscoped().Save(&it).Error; e != nil {
return created, updated, e
}
updated++
}
return created, updated, nil
}
// ImportMediaScanPaths 批量导入扫描路径
// 策略:按 (media_type, path) 唯一对去重:
// - 已存在则更新名称/标签等可编辑字段
// - 不存在则新建并返回新ID
//
// 返回值 idMap:导入数据中的原始ID -> 数据库实际ID 的映射,
// 供随后的 MediaItem 导入用于把 scan_path_id 重新指向新ID。
func ImportMediaScanPaths(paths []model.MediaScanPath) (idMap map[uint]uint, created, updated int, err error) {
idMap = make(map[uint]uint, len(paths))
for i := range paths {
p := paths[i]
originalID := p.ID
p.ID = 0
p.CreatedAt = time.Time{}
p.UpdatedAt = time.Time{}
p.DeletedAt = gorm.DeletedAt{}
var existing model.MediaScanPath
result := db.Where("media_type = ? AND path = ?", p.MediaType, p.Path).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
if e := db.Create(&p).Error; e != nil {
return idMap, created, updated, e
}
idMap[originalID] = p.ID
created++
continue
}
if result.Error != nil {
return idMap, created, updated, result.Error
}
// 已存在:更新可编辑字段
existing.Name = p.Name
existing.PathMerge = p.PathMerge
existing.TypeTag = p.TypeTag
existing.ContentTags = p.ContentTags
existing.EnableScrape = p.EnableScrape
if e := db.Save(&existing).Error; e != nil {
return idMap, created, updated, e
}
idMap[originalID] = existing.ID
updated++
}
return idMap, created, updated, nil
}
-663
View File
@@ -1,663 +0,0 @@
package media
import (
"encoding/binary"
"io"
"strconv"
"strings"
)
// MusicTag 音频文件标签信息
type MusicTag struct {
Title string // TIT2
Artist string // TPE1
Album string // TALB
AlbumArtist string // TPE2
TrackNumber int // TRCK
Year string // TYER / TDRC
Genre string // TCON
CoverData []byte // 封面图片原始字节(APIC / PICTURE)
CoverMIME string // 封面图片 MIME 类型(如 image/jpeg)
Lyrics string // 歌词内容(LRC 文本或纯文本):来自 USLT / SYLT / Vorbis LYRICS
}
// ParseID3v2 从 io.Reader 中解析 ID3v2 标签(只读取文件头部,不需要 Seek)
// 支持 ID3v2.3 和 ID3v2.4
func ParseID3v2(r io.Reader) (*MusicTag, error) {
// 读取 ID3v2 头部(10 字节)
header := make([]byte, 10)
if _, err := io.ReadFull(r, header); err != nil {
return nil, err
}
// 检查魔数
if string(header[0:3]) != "ID3" {
return nil, nil // 不是 ID3v2 文件,不报错
}
version := header[3] // 主版本号:3 = ID3v2.3, 4 = ID3v2.4
if version < 3 || version > 4 {
return nil, nil // 只支持 v2.3 和 v2.4
}
// 解析标签总大小(syncsafe integer,4 字节,每字节最高位为 0)
tagSize := syncsafeToInt(header[6:10])
if tagSize <= 0 || tagSize > 10*1024*1024 { // 最大 10MB
return nil, nil
}
// 读取所有帧数据
data := make([]byte, tagSize)
if _, err := io.ReadFull(r, data); err != nil {
return nil, nil // 读取失败时静默跳过
}
tag := &MusicTag{}
pos := 0
// 跳过扩展头部(如果有)
flags := header[5]
if flags&0x40 != 0 { // 有扩展头部
if pos+4 > len(data) {
return tag, nil
}
extSize := int(binary.BigEndian.Uint32(data[pos : pos+4]))
pos += extSize
}
// 解析帧
for pos+10 <= len(data) {
frameID := string(data[pos : pos+4])
if frameID == "\x00\x00\x00\x00" {
break // 填充区域,结束
}
frameSize := int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
pos += 10 // 跳过帧头(4+4+2)
if frameSize <= 0 || pos+frameSize > len(data) {
break
}
frameData := data[pos : pos+frameSize]
pos += frameSize
// 解析文本帧(T 开头的帧)
if len(frameID) == 4 && frameID[0] == 'T' && len(frameData) > 0 {
text := decodeID3Text(frameData)
switch frameID {
case "TIT2":
tag.Title = text
case "TPE1":
tag.Artist = text
case "TALB":
tag.Album = text
case "TPE2":
tag.AlbumArtist = text
case "TRCK":
// 格式可能是 "1" 或 "1/12"
parts := strings.SplitN(text, "/", 2)
if n, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
tag.TrackNumber = n
}
case "TYER", "TDRC":
if tag.Year == "" {
tag.Year = text
}
case "TCON":
tag.Genre = parseID3Genre(text)
}
}
// 解析 APIC 帧(内嵌封面图片),只取第一张
if frameID == "APIC" && tag.CoverData == nil && len(frameData) > 1 {
tag.CoverData, tag.CoverMIME = parseAPICFrame(frameData)
}
// 解析 USLT 帧(非同步歌词)
// 格式:编码字节(1) + 语言(3字节 ASCII) + 描述字符串(null 结尾) + 歌词文本
if frameID == "USLT" && tag.Lyrics == "" && len(frameData) > 5 {
tag.Lyrics = parseUSLTFrame(frameData)
}
// 解析 SYLT 帧(同步歌词,二进制时间戳格式),仅在没有 USLT 时尝试转 LRC
if frameID == "SYLT" && tag.Lyrics == "" && len(frameData) > 6 {
if lrc := parseSYLTFrame(frameData); lrc != "" {
tag.Lyrics = lrc
}
}
}
return tag, nil
}
// parseUSLTFrame 解析 ID3v2 USLT 帧(非同步歌词)
// 格式:编码字节(1) + 语言(3字节 ASCII) + 描述字符串(null 结尾) + 歌词文本
func parseUSLTFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
// 跳过编码(1) + 语言(3) = 4 字节
pos := 4
// 跳过描述字符串
if encoding == 0x01 || encoding == 0x02 {
// UTF-16:找 \x00\x00 结尾
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos += 2
}
} else {
// ISO-8859-1 / UTF-8:找单个 \x00 结尾
for pos < len(data) {
if data[pos] == 0 {
pos++
break
}
pos++
}
}
if pos >= len(data) {
return ""
}
// 剩余字节就是歌词文本,按编码解码(复用 decodeID3Text 但需要把 encoding 字节加回头部)
lyricsRaw := make([]byte, 1+len(data)-pos)
lyricsRaw[0] = encoding
copy(lyricsRaw[1:], data[pos:])
return strings.TrimSpace(decodeID3Text(lyricsRaw))
}
// parseSYLTFrame 解析 ID3v2 SYLT 帧(同步歌词),转换为 LRC 格式
// 格式:编码(1) + 语言(3) + 时间戳格式(1) + 内容类型(1) + 描述(null 结尾) + 多组 (歌词文本 + null + 4字节时间戳)
// 时间戳格式:1=MPEG帧数(不支持),2=毫秒
func parseSYLTFrame(data []byte) string {
if len(data) < 6 {
return ""
}
encoding := data[0]
timestampFormat := data[4] // 1=MPEG帧, 2=ms
if timestampFormat != 2 {
return "" // 只支持毫秒时间戳
}
pos := 6 // 跳过 encoding(1) + lang(3) + timestamp_fmt(1) + content_type(1)
// 跳过描述字符串
if encoding == 0x01 || encoding == 0x02 {
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos += 2
}
} else {
for pos < len(data) {
if data[pos] == 0 {
pos++
break
}
pos++
}
}
var sb strings.Builder
wide := encoding == 0x01 || encoding == 0x02
for pos < len(data) {
// 找文本终止符
textStart := pos
textEnd := -1
if wide {
for i := pos; i+1 < len(data); i += 2 {
if data[i] == 0 && data[i+1] == 0 {
textEnd = i
break
}
}
} else {
for i := pos; i < len(data); i++ {
if data[i] == 0 {
textEnd = i
break
}
}
}
if textEnd < 0 {
break
}
// 解码歌词文本
lineRaw := make([]byte, 1+textEnd-textStart)
lineRaw[0] = encoding
copy(lineRaw[1:], data[textStart:textEnd])
lineText := decodeID3Text(lineRaw)
// 跳过文本终止符
if wide {
pos = textEnd + 2
} else {
pos = textEnd + 1
}
// 4 字节时间戳(毫秒)
if pos+4 > len(data) {
break
}
ms := int(binary.BigEndian.Uint32(data[pos : pos+4]))
pos += 4
minutes := ms / 60000
seconds := (ms % 60000) / 1000
cs := (ms % 1000) / 10
// LRC 标准格式 [mm:ss.xx]
sb.WriteString("[")
if minutes < 10 {
sb.WriteString("0")
}
sb.WriteString(strconv.Itoa(minutes))
sb.WriteString(":")
if seconds < 10 {
sb.WriteString("0")
}
sb.WriteString(strconv.Itoa(seconds))
sb.WriteString(".")
if cs < 10 {
sb.WriteString("0")
}
sb.WriteString(strconv.Itoa(cs))
sb.WriteString("]")
sb.WriteString(lineText)
sb.WriteString("\n")
}
return sb.String()
}
// parseAPICFrame 解析 ID3v2 APIC 帧,返回图片字节和 MIME 类型
// APIC 格式:编码字节(1) + MIME字符串(null结尾) + 图片类型(1) + 描述字符串(null结尾) + 图片数据
func parseAPICFrame(data []byte) ([]byte, string) {
if len(data) < 4 {
return nil, ""
}
// encoding := data[0] // 文本编码(只影响描述字段,MIME 始终是 ASCII)
pos := 1
// 读取 MIME 类型(null 结尾的 ASCII 字符串)
nullIdx := -1
for i := pos; i < len(data); i++ {
if data[i] == 0 {
nullIdx = i
break
}
}
if nullIdx < 0 {
return nil, ""
}
mimeType := string(data[pos:nullIdx])
pos = nullIdx + 1
if pos >= len(data) {
return nil, ""
}
// 图片类型(1字节,3 = Cover (front),但我们取任意第一张)
pos++ // 跳过图片类型字节
// 跳过描述字符串(null 结尾,编码由第一字节决定)
encoding := data[0]
if encoding == 0x01 || encoding == 0x02 {
// UTF-16:找 \x00\x00 结尾
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos += 2
}
} else {
// ISO-8859-1 / UTF-8:找单个 \x00 结尾
for pos < len(data) {
if data[pos] == 0 {
pos++
break
}
pos++
}
}
if pos >= len(data) {
return nil, ""
}
// 标准化 MIME 类型
if mimeType == "" || mimeType == "image/" {
mimeType = "image/jpeg" // 默认
}
imgData := make([]byte, len(data)-pos)
copy(imgData, data[pos:])
return imgData, mimeType
}
// syncsafeToInt 将 syncsafe integer 转换为普通整数
func syncsafeToInt(b []byte) int {
result := 0
for _, v := range b {
result = (result << 7) | int(v&0x7F)
}
return result
}
// decodeID3Text 解码 ID3 文本帧(第一字节是编码标识)
func decodeID3Text(data []byte) string {
if len(data) == 0 {
return ""
}
encoding := data[0]
content := data[1:]
// 去掉末尾的 null 字节
switch encoding {
case 0x00: // ISO-8859-1
// 去掉末尾 null
content = trimNull(content, false)
// 尝试 UTF-8 解码,如果失败则按 Latin-1 处理
return latin1ToUTF8(content)
case 0x01: // UTF-16 with BOM
content = trimNull(content, true)
return utf16ToUTF8(content)
case 0x02: // UTF-16 BE without BOM
content = trimNull(content, true)
return utf16BEToUTF8(content)
case 0x03: // UTF-8
content = trimNull(content, false)
return string(content)
default:
return string(content)
}
}
// trimNull 去掉末尾的 null 字节
func trimNull(b []byte, wide bool) []byte {
if wide {
// UTF-16: 去掉末尾的 \x00\x00
for len(b) >= 2 && b[len(b)-2] == 0 && b[len(b)-1] == 0 {
b = b[:len(b)-2]
}
} else {
for len(b) > 0 && b[len(b)-1] == 0 {
b = b[:len(b)-1]
}
}
return b
}
// latin1ToUTF8 将 Latin-1 编码转换为 UTF-8
func latin1ToUTF8(b []byte) string {
runes := make([]rune, len(b))
for i, v := range b {
runes[i] = rune(v)
}
return string(runes)
}
// utf16ToUTF8 将 UTF-16(带 BOM)转换为 UTF-8
func utf16ToUTF8(b []byte) string {
if len(b) < 2 {
return ""
}
var bigEndian bool
if b[0] == 0xFF && b[1] == 0xFE {
bigEndian = false
b = b[2:]
} else if b[0] == 0xFE && b[1] == 0xFF {
bigEndian = true
b = b[2:]
}
return decodeUTF16(b, bigEndian)
}
// utf16BEToUTF8 将 UTF-16 BE(无 BOM)转换为 UTF-8
func utf16BEToUTF8(b []byte) string {
return decodeUTF16(b, true)
}
// decodeUTF16 解码 UTF-16 字节序列
func decodeUTF16(b []byte, bigEndian bool) string {
if len(b)%2 != 0 {
b = b[:len(b)-1]
}
runes := make([]rune, 0, len(b)/2)
for i := 0; i+1 < len(b); i += 2 {
var code uint16
if bigEndian {
code = uint16(b[i])<<8 | uint16(b[i+1])
} else {
code = uint16(b[i+1])<<8 | uint16(b[i])
}
runes = append(runes, rune(code))
}
return string(runes)
}
// parseID3Genre 解析 ID3 流派字段(可能是 "(17)" 格式的数字引用)
func parseID3Genre(s string) string {
s = strings.TrimSpace(s)
if len(s) > 2 && s[0] == '(' && s[len(s)-1] == ')' {
// 数字引用格式,直接返回原始字符串
return s
}
return s
}
// ─── FLAC Vorbis Comment 解析 ────────────────────────────────────────────────
// ParseFLACVorbisComment 从 io.Reader 中解析 FLAC 文件的 Vorbis Comment 元数据
// FLAC 格式:4字节魔数 "fLaC" + 若干 METADATA_BLOCK
// METADATA_BLOCK:1字节(最高位=是否最后块, 低7位=块类型) + 3字节长度 + 数据
// 块类型 4 = VORBIS_COMMENT
func ParseFLACVorbisComment(r io.Reader) (*MusicTag, error) {
// 读取魔数(4字节)
magic := make([]byte, 4)
if _, err := io.ReadFull(r, magic); err != nil {
return nil, err
}
if string(magic) != "fLaC" {
return nil, nil // 不是 FLAC 文件
}
// 遍历 METADATA_BLOCK,找到 VORBIS_COMMENT(类型4)和 PICTURE(类型6)
var tag *MusicTag
for {
// 读取块头(4字节:1字节标志+类型 + 3字节长度)
blockHeader := make([]byte, 4)
if _, err := io.ReadFull(r, blockHeader); err != nil {
return nil, nil // 读取失败,静默跳过
}
isLast := blockHeader[0]&0x80 != 0
blockType := blockHeader[0] & 0x7F
blockLen := int(blockHeader[1])<<16 | int(blockHeader[2])<<8 | int(blockHeader[3])
if blockLen < 0 || blockLen > 16*1024*1024 { // 最大 16MB
return nil, nil
}
if blockType == 4 {
// VORBIS_COMMENT 块
data := make([]byte, blockLen)
if _, err := io.ReadFull(r, data); err != nil {
return nil, nil
}
tag = parseVorbisCommentData(data)
if isLast {
return tag, nil
}
// 继续读取后续块,寻找 PICTURE 块(类型6)
continue
}
if blockType == 6 {
// PICTURE 块(FLAC 内嵌封面)
data := make([]byte, blockLen)
if _, err := io.ReadFull(r, data); err == nil && tag != nil && tag.CoverData == nil {
tag.CoverData, tag.CoverMIME = parseFLACPictureBlock(data)
} else if err != nil {
// 读取失败,跳过
_ = err
}
if isLast {
return tag, nil
}
continue
}
// 跳过此块
if _, err := io.CopyN(io.Discard, r, int64(blockLen)); err != nil {
return nil, nil
}
if isLast {
break
}
}
return tag, nil
}
// parseFLACPictureBlock 解析 FLAC PICTURE 元数据块,返回图片字节和 MIME 类型
// 格式(大端序):
// 4字节 picture_type
// 4字节 mime_length + mime_string
// 4字节 description_length + description_string
// 4字节 width, 4字节 height, 4字节 color_depth, 4字节 color_count
// 4字节 data_length + data
func parseFLACPictureBlock(data []byte) ([]byte, string) {
if len(data) < 8 {
return nil, ""
}
pos := 4 // 跳过 picture_type
// 读取 MIME 类型
if pos+4 > len(data) {
return nil, ""
}
mimeLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
pos += 4
if pos+mimeLen > len(data) {
return nil, ""
}
mimeType := string(data[pos : pos+mimeLen])
pos += mimeLen
// 跳过描述字符串
if pos+4 > len(data) {
return nil, ""
}
descLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
pos += 4 + descLen
// 跳过 width(4) + height(4) + color_depth(4) + color_count(4)
pos += 16
// 读取图片数据
if pos+4 > len(data) {
return nil, ""
}
dataLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
pos += 4
if pos+dataLen > len(data) {
return nil, ""
}
if mimeType == "" {
mimeType = "image/jpeg"
}
imgData := make([]byte, dataLen)
copy(imgData, data[pos:pos+dataLen])
return imgData, mimeType
}
// parseVorbisCommentData 解析 Vorbis Comment 数据块
// 格式(小端序):
// 4字节 vendor_length + vendor_string
// 4字节 comment_count
// 每条注释:4字节长度 + UTF-8字符串(格式 "KEY=VALUE")
func parseVorbisCommentData(data []byte) *MusicTag {
tag := &MusicTag{}
pos := 0
// 跳过 vendor string
if pos+4 > len(data) {
return tag
}
vendorLen := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
pos += 4 + vendorLen
// 读取注释数量
if pos+4 > len(data) {
return tag
}
commentCount := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
pos += 4
for i := 0; i < commentCount; i++ {
if pos+4 > len(data) {
break
}
commentLen := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
pos += 4
if commentLen <= 0 || pos+commentLen > len(data) {
break
}
comment := string(data[pos : pos+commentLen])
pos += commentLen
// 解析 "KEY=VALUE" 格式(大小写不敏感)
eqIdx := strings.IndexByte(comment, '=')
if eqIdx < 0 {
continue
}
key := strings.ToUpper(comment[:eqIdx])
value := strings.TrimSpace(comment[eqIdx+1:])
if value == "" {
continue
}
switch key {
case "TITLE":
tag.Title = value
case "ARTIST":
if tag.Artist == "" {
tag.Artist = value
}
case "ALBUM":
tag.Album = value
case "ALBUMARTIST", "ALBUM ARTIST":
tag.AlbumArtist = value
case "TRACKNUMBER", "TRACK":
// 格式可能是 "1" 或 "1/12"
parts := strings.SplitN(value, "/", 2)
if n, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
tag.TrackNumber = n
}
case "DATE", "YEAR":
if tag.Year == "" && len(value) >= 4 {
tag.Year = value[:4]
}
case "GENRE":
tag.Genre = value
case "LYRICS", "UNSYNCEDLYRICS", "UNSYNCED LYRICS", "SYNCEDLYRICS", "SYNCED LYRICS":
// FLAC/Vorbis 内嵌歌词字段(不同打标软件使用不同 key,全部兼容)
if tag.Lyrics == "" {
tag.Lyrics = value
}
}
}
return tag
}
-130
View File
@@ -1,130 +0,0 @@
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)
}
-736
View File
@@ -1,736 +0,0 @@
package media
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"mime"
stdpath "path"
"regexp"
"strings"
"sync"
"time"
"unicode"
log "github.com/sirupsen/logrus"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// 支持的文件扩展名
var (
videoExts = map[string]bool{
".mp4": true, ".mkv": true, ".avi": true, ".mov": true,
".wmv": true, ".flv": true, ".webm": true, ".m4v": true,
".ts": true, ".rmvb": true, ".rm": true, ".3gp": true,
}
musicExts = map[string]bool{
".mp3": true, ".flac": true, ".aac": true, ".ogg": true,
".wav": true, ".wma": true, ".m4a": true, ".ape": true,
".opus": true, ".aiff": true,
}
imageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".webp": true, ".bmp": true, ".tiff": true, ".svg": true,
".heic": true, ".avif": true,
}
bookExts = map[string]bool{
".epub": true, ".pdf": true, ".mobi": true, ".azw3": true,
".txt": true, ".djvu": true, ".cbz": true, ".cbr": true,
}
)
// ScanProgress 扫描进度(全局,按媒体类型维护)
type ScanProgress struct {
mu sync.RWMutex
Running bool
Total int
Done int
Message string
Error string
}
var progressMap = map[model.MediaType]*ScanProgress{
model.MediaTypeVideo: {},
model.MediaTypeMusic: {},
model.MediaTypeImage: {},
model.MediaTypeBook: {},
}
// GetProgress 获取扫描进度
func GetProgress(mediaType model.MediaType) model.MediaScanProgress {
p, ok := progressMap[mediaType]
if !ok {
return model.MediaScanProgress{MediaType: mediaType}
}
p.mu.RLock()
defer p.mu.RUnlock()
return model.MediaScanProgress{
MediaType: mediaType,
Running: p.Running,
Total: p.Total,
Done: p.Done,
Message: p.Message,
Error: p.Error,
}
}
// ScanMedia 扫描媒体文件(异步),扫描指定媒体类型下的所有启用扫描路径
func ScanMedia(cfg *model.MediaConfig) {
p, ok := progressMap[cfg.MediaType]
if !ok {
return
}
p.mu.Lock()
if p.Running {
p.mu.Unlock()
return
}
p.Running = true
p.Total = 0
p.Done = 0
p.Error = ""
p.Message = "正在扫描..."
p.mu.Unlock()
go func() {
defer func() {
p.mu.Lock()
p.Running = false
p.mu.Unlock()
}()
// 获取该媒体类型下所有扫描路径
scanPaths, err := db.ListMediaScanPaths(cfg.MediaType)
if err != nil {
p.mu.Lock()
p.Error = err.Error()
p.Message = "获取扫描路径失败"
p.mu.Unlock()
return
}
if len(scanPaths) == 0 {
p.mu.Lock()
p.Message = "没有配置扫描路径"
p.mu.Unlock()
return
}
for i := range scanPaths {
sp := &scanPaths[i]
p.mu.Lock()
p.Message = "正在扫描路径: " + sp.Name
p.mu.Unlock()
if err := doScanPath(sp, p); err != nil {
log.Errorf("media scan error [%s] path[%s]: %v", cfg.MediaType, sp.Path, err)
} else {
// 更新最后扫描时间
now := time.Now()
sp.LastScanAt = &now
_ = db.UpdateMediaScanPath(sp)
}
}
p.mu.Lock()
p.Message = "扫描完成"
p.mu.Unlock()
// 更新媒体库最后扫描时间
now := time.Now()
cfg.LastScanAt = &now
_ = db.SaveMediaConfig(cfg)
}()
}
// ScanMediaPath 扫描单个扫描路径(异步)
func ScanMediaPath(sp *model.MediaScanPath) {
p, ok := progressMap[sp.MediaType]
if !ok {
return
}
p.mu.Lock()
if p.Running {
p.mu.Unlock()
return
}
p.Running = true
p.Total = 0
p.Done = 0
p.Error = ""
p.Message = "正在扫描路径: " + sp.Name
p.mu.Unlock()
go func() {
defer func() {
p.mu.Lock()
p.Running = false
p.mu.Unlock()
}()
if err := doScanPath(sp, p); err != nil {
p.mu.Lock()
p.Error = err.Error()
p.Message = "扫描失败"
p.mu.Unlock()
log.Errorf("media scan error [%s] path[%s]: %v", sp.MediaType, sp.Path, err)
} else {
p.mu.Lock()
p.Message = "扫描完成"
p.mu.Unlock()
now := time.Now()
sp.LastScanAt = &now
_ = db.UpdateMediaScanPath(sp)
}
}()
}
func doScanPath(sp *model.MediaScanPath, p *ScanProgress) error {
scanRoot := sp.Path
if scanRoot == "" {
scanRoot = "/"
}
// 收集所有待处理路径(VFS 虚拟路径)
var targets []string
ctx := context.Background()
if sp.PathMerge {
// 路径合并模式:
// - 子文件夹 → 作为一个合并条目(带 Episodes 选集信息)
// - 直接放在根目录下的单个媒体文件 → 正常作为独立条目扫描
entries, err := fs.List(ctx, scanRoot, &fs.ListArgs{NoLog: true, Refresh: true})
if err != nil {
return err
}
for _, e := range entries {
childPath := stdpath.Join(scanRoot, e.GetName())
if e.IsDir() {
targets = append(targets, childPath)
} else if isMediaFile(e.GetName(), sp.MediaType) {
targets = append(targets, childPath)
}
}
} else {
// 普通模式:递归扫描所有匹配文件(每个目录都刷新缓存)
if err := walkVFS(ctx, scanRoot, sp.MediaType, &targets); err != nil {
return err
}
}
p.mu.Lock()
p.Total += len(targets)
p.mu.Unlock()
// 音乐类型:按专辑合并处理
if sp.MediaType == model.MediaTypeMusic {
return doScanMusicByAlbum(ctx, targets, sp, p)
}
for _, target := range targets {
item, err := buildMediaItemFromVFS(ctx, target, sp)
if err != nil {
log.Warnf("build media item error [%s]: %v", target, err)
continue
}
// 路径合并模式下,扫描文件夹内的文件,填充选集信息
if sp.PathMerge && item.IsFolder {
if episodes, err := buildEpisodesFromFolder(ctx, target, sp.MediaType); err == nil {
item.Episodes = episodes
} else {
log.Warnf("build episodes error [%s]: %v", target, err)
}
}
if err := db.CreateOrUpdateMediaItem(item); err != nil {
log.Warnf("save media item error [%s]: %v", target, err)
}
p.mu.Lock()
p.Done++
p.Message = "已扫描: " + stdpath.Base(target)
p.mu.Unlock()
}
return nil
}
// doScanMusicByAlbum 音乐扫描
// 存储规则:
// - 普通文件(is_folder=false):
// folder_path=文件所在真实目录,file_name=音乐文件名,episodes=空
// - 合并文件夹(is_folder=true):
// folder_path=文件夹所在真实父目录,file_name=文件夹名,
// episodes=文件夹内所有音乐文件列表,封面/标签取第一首
func doScanMusicByAlbum(ctx context.Context, targets []string, sp *model.MediaScanPath, p *ScanProgress) error {
for _, target := range targets {
obj, err := fs.Get(ctx, target, &fs.GetArgs{NoLog: true})
if err != nil {
log.Warnf("get vfs object error [%s]: %v", target, err)
p.mu.Lock()
p.Done++
p.mu.Unlock()
continue
}
name := obj.GetName()
if obj.IsDir() {
// ---- 合并文件夹模式:文件夹条目 ----
// 列出文件夹内所有音乐文件
entries, err := fs.List(ctx, target, &fs.ListArgs{NoLog: true})
if err != nil {
log.Warnf("list music folder error [%s]: %v", target, err)
p.mu.Lock()
p.Done++
p.mu.Unlock()
continue
}
var musicFiles []string
var episodes []EpisodeInfo
for idx, e := range entries {
if e.IsDir() || !isMediaFile(e.GetName(), sp.MediaType) {
continue
}
musicFiles = append(musicFiles, stdpath.Join(target, e.GetName()))
ep := EpisodeInfo{
FileName: e.GetName(),
Index: idx + 1,
Title: strings.TrimSuffix(e.GetName(), stdpath.Ext(e.GetName())),
}
episodes = append(episodes, ep)
}
if len(musicFiles) == 0 {
p.mu.Lock()
p.Done++
p.mu.Unlock()
continue
}
// 取第一首音乐文件读取标签
firstFile := musicFiles[0]
firstExt := strings.ToLower(stdpath.Ext(firstFile))
var tag *MusicTag
readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second)
if reader := FetchFileReader(readCtx, firstFile); reader != nil {
switch firstExt {
case ".flac":
tag, _ = ParseFLACVorbisComment(reader)
default:
tag, _ = ParseID3v2(reader)
}
_ = reader.Close()
}
readCancel()
// 解析标签(用文件夹名作为默认专辑名)
albumName := name
albumArtist := ""
cover := ""
releaseDate := ""
genre := ""
authors := ""
if tag != nil {
if tag.Album != "" {
albumName = tag.Album
}
albumArtist = tag.AlbumArtist
if albumArtist == "" {
albumArtist = tag.Artist
}
if tag.Year != "" && len(tag.Year) >= 4 {
releaseDate = tag.Year[:4] + "-01-01"
}
if tag.Genre != "" {
genre = tag.Genre
}
if tag.Artist != "" {
if authorsJSON, err := json.Marshal([]string{tag.Artist}); err == nil {
authors = string(authorsJSON)
}
}
if len(tag.CoverData) > 0 {
mimeType := tag.CoverMIME
if mimeType == "" {
mimeType = "image/jpeg"
}
cover = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData)
}
}
episodesJSON := ""
if b, err := json.Marshal(episodes); err == nil {
episodesJSON = string(b)
}
// 加载歌词:同目录同名 .lrc 优先,其次内嵌歌词
lyrics := loadLyricsForMusic(ctx, firstFile, tag)
item := &model.MediaItem{
MediaType: sp.MediaType,
ScanPathID: sp.ID,
FileName: name, // 文件夹名
FolderPath: stdpath.Dir(target), // 文件夹所在的真实父目录
IsFolder: true,
AlbumName: albumName,
AlbumArtist: albumArtist,
ScrapedName: albumName,
Cover: cover,
ReleaseDate: releaseDate,
Genre: genre,
Authors: authors,
Lyrics: lyrics,
Episodes: episodesJSON,
}
if err := db.CreateOrUpdateMediaItem(item); err != nil {
log.Warnf("save music folder item error [%s]: %v", target, err)
}
} else {
// ---- 普通文件:每首歌独立一条记录 ----
folderPath := stdpath.Dir(target)
ext := strings.ToLower(stdpath.Ext(name))
var tag *MusicTag
readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second)
if reader := FetchFileReader(readCtx, target); reader != nil {
switch ext {
case ".flac":
tag, _ = ParseFLACVorbisComment(reader)
default:
tag, _ = ParseID3v2(reader)
}
_ = reader.Close()
}
readCancel()
albumName := ""
albumArtist := ""
trackTitle := strings.TrimSuffix(name, stdpath.Ext(name))
trackNumber := 0
cover := ""
releaseDate := ""
genre := ""
authors := ""
if tag != nil {
albumName = tag.Album
albumArtist = tag.AlbumArtist
if albumArtist == "" {
albumArtist = tag.Artist
}
if tag.Title != "" {
trackTitle = tag.Title
}
trackNumber = tag.TrackNumber
if tag.Year != "" && len(tag.Year) >= 4 {
releaseDate = tag.Year[:4] + "-01-01"
}
if tag.Genre != "" {
genre = tag.Genre
}
if tag.Artist != "" {
if authorsJSON, err := json.Marshal([]string{tag.Artist}); err == nil {
authors = string(authorsJSON)
}
}
if len(tag.CoverData) > 0 {
mimeType := tag.CoverMIME
if mimeType == "" {
mimeType = "image/jpeg"
}
cover = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData)
}
}
// 加载歌词:同目录同名 .lrc 优先,其次内嵌歌词
lyrics := loadLyricsForMusic(ctx, target, tag)
item := &model.MediaItem{
MediaType: sp.MediaType,
ScanPathID: sp.ID,
FileName: name,
FolderPath: folderPath,
IsFolder: false,
FileSize: obj.GetSize(),
MimeType: mime.TypeByExtension(ext),
AlbumName: albumName,
AlbumArtist: albumArtist,
ScrapedName: trackTitle,
Cover: cover,
ReleaseDate: releaseDate,
Genre: genre,
Authors: authors,
TrackNumber: trackNumber,
Lyrics: lyrics,
}
if err := db.CreateOrUpdateMediaItem(item); err != nil {
log.Warnf("save music item error [%s]: %v", target, err)
}
}
p.mu.Lock()
p.Done++
p.Message = "已扫描: " + name
p.mu.Unlock()
}
return nil
}
// loadLyricsForMusic 为音乐文件加载歌词:
// 1. 优先读取同目录同名 .lrc 文件(如 song.mp3 → song.lrc)
// 2. 如果不存在,使用 tag 中的内嵌歌词(USLT / Vorbis LYRICS)
//
// musicVfsPath 为音乐文件的 VFS 全路径;tag 可为 nil
func loadLyricsForMusic(ctx context.Context, musicVfsPath string, tag *MusicTag) string {
// 尝试同目录同名 .lrc
ext := stdpath.Ext(musicVfsPath)
if ext != "" {
lrcPath := strings.TrimSuffix(musicVfsPath, ext) + ".lrc"
lrcCtx, lrcCancel := context.WithTimeout(ctx, 8*time.Second)
reader := FetchFileReader(lrcCtx, lrcPath)
if reader != nil {
// 限制读取上限 1MB,避免异常文件压垮内存
data, err := io.ReadAll(io.LimitReader(reader, 1024*1024))
_ = reader.Close()
lrcCancel()
if err == nil && len(data) > 0 {
// 去掉可能的 UTF-8 BOM
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
s := strings.TrimSpace(string(data))
if s != "" {
return s
}
}
} else {
lrcCancel()
}
}
// 回退到内嵌歌词
if tag != nil && tag.Lyrics != "" {
return tag.Lyrics
}
return ""
}
// FetchFileReader 通过 VFS 路径获取文件内容流(用于刮削器读取文件内容)
// 优先使用 RangeReader 直接读取(本地存储无需 HTTP),失败时回退到 HTTP URL
// 返回 nil 表示无法获取(不影响主流程)
func FetchFileReader(ctx context.Context, vfsPath string) io.ReadCloser {
link, file, err := fs.Link(ctx, vfsPath, model.LinkArgs{})
if err != nil || link == nil {
return nil
}
size := link.ContentLength
if size <= 0 {
size = file.GetSize()
}
rr, err := stream.GetRangeReaderFromLink(size, link)
if err != nil {
_ = link.Close()
return nil
}
rc, err := rr.RangeRead(ctx, http_range.Range{Length: size})
if err != nil {
_ = link.Close()
return nil
}
link.SyncClosers.Add(rc)
return utils.ReadCloser{Reader: rc, Closer: link}
}
// walkVFS 递归遍历 VFS 路径,收集匹配的媒体文件路径(每个目录都刷新缓存)
func walkVFS(ctx context.Context, dirPath string, mediaType model.MediaType, targets *[]string) error {
entries, err := fs.List(ctx, dirPath, &fs.ListArgs{NoLog: true, Refresh: true})
if err != nil {
log.Warnf("media scan: list vfs path [%s] error: %v", dirPath, err)
return nil // 跳过无权限目录,不中断整体扫描
}
for _, e := range entries {
childPath := stdpath.Join(dirPath, e.GetName())
if e.IsDir() {
if err := walkVFS(ctx, childPath, mediaType, targets); err != nil {
return err
}
} else if isMediaFile(e.GetName(), mediaType) {
*targets = append(*targets, childPath)
}
}
return nil
}
// buildMediaItemFromVFS 根据 VFS 路径构建 MediaItem(非音乐类型使用)
// 存储规则:
// - folder_path:文件/文件夹所在的真实父目录(VFS 父路径),用于前端按 folder_path + file_name 反查真实文件
// - file_name:文件夹就是文件夹名,文件就是文件名
// - episodes:文件夹时存里面每个文件的信息,文件时为空
func buildMediaItemFromVFS(ctx context.Context, vfsPath string, sp *model.MediaScanPath) (*model.MediaItem, error) {
obj, err := fs.Get(ctx, vfsPath, &fs.GetArgs{NoLog: true})
if err != nil {
return nil, err
}
name := obj.GetName()
ext := strings.ToLower(stdpath.Ext(name))
item := &model.MediaItem{
MediaType: sp.MediaType,
ScanPathID: sp.ID,
FileName: name, // 文件夹名 或 文件名
FolderPath: stdpath.Dir(vfsPath), // 真实父目录
IsFolder: obj.IsDir(),
ScrapedName: strings.TrimSuffix(name, stdpath.Ext(name)), // 去掉扩展名作为默认名称
}
if !obj.IsDir() {
// 普通文件:记录大小和 MIME 类型,episodes 为空
item.FileSize = obj.GetSize()
item.MimeType = mime.TypeByExtension(ext)
}
return item, nil
}
// episodeNumRe 匹配文件名开头的数字序号,支持 "1、" "2." "3-" "4 " 等分隔符
var episodeNumRe = regexp.MustCompile(`^(\d+)[、.\-\s_]+(.*)`)
// EpisodeInfo 选集信息
type EpisodeInfo struct {
FileName string `json:"file_name"` // 原始文件名(含扩展名)
Index int `json:"index"` // 序号,默认0,文件名开头有数字则取该数字
Title string `json:"title"` // 选集标题(去掉序号后的文件名,不含扩展名)
}
// buildEpisodesFromFolder 扫描文件夹内的媒体文件,构建选集信息 JSON 字符串
func buildEpisodesFromFolder(ctx context.Context, folderPath string, mediaType model.MediaType) (string, error) {
entries, err := fs.List(ctx, folderPath, &fs.ListArgs{NoLog: true})
if err != nil {
return "", err
}
var episodes []EpisodeInfo
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.GetName()
if !isMediaFile(name, mediaType) {
continue
}
// 去掉扩展名得到裸文件名
ext := stdpath.Ext(name)
baseName := strings.TrimSuffix(name, ext)
ep := EpisodeInfo{
FileName: name,
Index: 0,
Title: baseName,
}
// 尝试从文件名开头提取数字序号
if m := episodeNumRe.FindStringSubmatch(baseName); len(m) == 3 {
if idx := parseLeadingInt(m[1]); idx > 0 {
ep.Index = idx
ep.Title = strings.TrimSpace(m[2])
}
} else {
// 文件名直接以纯数字开头(无分隔符),也尝试提取
if idx, rest := splitLeadingNumber(baseName); idx > 0 {
ep.Index = idx
ep.Title = strings.TrimSpace(rest)
}
}
episodes = append(episodes, ep)
}
if len(episodes) == 0 {
return "", nil
}
b, err := json.Marshal(episodes)
if err != nil {
return "", err
}
return string(b), nil
}
// parseLeadingInt 将纯数字字符串解析为 int,失败返回 0
func parseLeadingInt(s string) int {
v := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
v = v*10 + int(c-'0')
}
return v
}
// splitLeadingNumber 从字符串开头提取连续数字,返回 (数字值, 剩余字符串)
// 仅当开头确实有数字时才返回非零值
func splitLeadingNumber(s string) (int, string) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i == len(s) {
// 没有数字,或者整个字符串都是数字(没有标题部分)
return 0, s
}
// 剩余部分必须以非字母数字字符开头,避免把 "1080p" 之类的误识别
if unicode.IsLetter(rune(s[i])) || unicode.IsDigit(rune(s[i])) {
return 0, s
}
v := parseLeadingInt(s[:i])
return v, s[i:]
}
// isMediaFile 判断文件名是否为指定媒体类型(按扩展名判断)
func isMediaFile(name string, mediaType model.MediaType) bool {
ext := strings.ToLower(stdpath.Ext(name))
switch mediaType {
case model.MediaTypeVideo:
return videoExts[ext]
case model.MediaTypeMusic:
return musicExts[ext]
case model.MediaTypeImage:
return imageExts[ext]
case model.MediaTypeBook:
return bookExts[ext]
}
return false
}
// GetSupportedExts 获取指定媒体类型支持的扩展名列表
func GetSupportedExts(mediaType model.MediaType) []string {
var extMap map[string]bool
switch mediaType {
case model.MediaTypeVideo:
extMap = videoExts
case model.MediaTypeMusic:
extMap = musicExts
case model.MediaTypeImage:
extMap = imageExts
case model.MediaTypeBook:
extMap = bookExts
default:
return nil
}
exts := make([]string, 0, len(extMap))
for ext := range extMap {
exts = append(exts, ext)
}
return exts
}
-402
View File
@@ -1,402 +0,0 @@
package scraper
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/xml"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
// BookLocalScraper 书籍本地刮削器
// 尝试从书籍文件中提取封面图片并以 base64 存入 Cover 字段,或保存到本地文件。
// - EPUB:解压 zip,提取 OPF 声明的封面或 cover.jpg/cover.png
// - PDF:尝试提取内嵌 JPEG 图片流作为封面
// - 其他/失败:降级为文件路径
type BookLocalScraper struct {
// ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件)
ThumbnailMode string
// ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail"
ThumbnailPath string
}
// NewBookLocalScraper 创建书籍本地刮削器
func NewBookLocalScraper() *BookLocalScraper {
return &BookLocalScraper{
ThumbnailMode: "base64",
ThumbnailPath: "/.thumbnail",
}
}
// NewBookLocalScraperWithConfig 创建带完整配置的书籍本地刮削器
func NewBookLocalScraperWithConfig(thumbnailMode, thumbnailPath string) *BookLocalScraper {
if thumbnailMode == "" {
thumbnailMode = "base64"
}
if thumbnailPath == "" {
thumbnailPath = "/.thumbnail"
}
return &BookLocalScraper{
ThumbnailMode: thumbnailMode,
ThumbnailPath: thumbnailPath,
}
}
// ScrapeBookLocal 从书籍文件流中提取封面图片,填充基本信息。
// reader 为书籍文件内容流,不能为 nil。
// 返回提取到的封面 base64 字符串(data URI),提取失败返回空字符串。
// 注意:本函数不会将文件路径作为 cover 降级,cover 为空表示提取失败。
func (s *BookLocalScraper) ScrapeBookLocal(item *model.MediaItem, reader io.Reader) error {
ext := strings.ToLower(getFileExt(item.FileName))
// 根据文件类型补充 MimeType
if item.MimeType == "" {
item.MimeType = mimeTypeByBookExt(ext)
}
// 去掉扩展名作为展示名
if item.ScrapedName == "" {
item.ScrapedName = trimExt(item.FileName)
}
// 从文件流提取封面,提取失败则 cover 保持为空
if reader != nil {
data, err := io.ReadAll(reader)
if err == nil && len(data) > 0 {
var coverData []byte
switch ext {
case ".epub":
coverData = extractEpubCoverData(data)
case ".pdf":
coverData = extractPDFCoverData(data)
}
if len(coverData) > 0 {
cover := s.storeCover(item.FolderPath, coverData)
if cover != "" {
item.Cover = cover
}
}
}
}
now := time.Now()
item.ScrapedAt = &now
return nil
}
// ExtractLocalCover 从书籍文件流中提取封面并根据配置存储(供外部调用)。
// 提取失败返回空字符串,不降级为文件路径。
func (s *BookLocalScraper) ExtractLocalCover(fileName string, filePath string, reader io.Reader) string {
if reader == nil {
return ""
}
ext := strings.ToLower(getFileExt(fileName))
data, err := io.ReadAll(reader)
if err != nil || len(data) == 0 {
return ""
}
var coverData []byte
switch ext {
case ".epub":
coverData = extractEpubCoverData(data)
case ".pdf":
coverData = extractPDFCoverData(data)
}
if len(coverData) == 0 {
return ""
}
return s.storeCover(filePath, coverData)
}
// storeCover 根据 ThumbnailMode 存储封面数据,返回 Cover 字段值
func (s *BookLocalScraper) storeCover(filePath string, coverData []byte) string {
if s.ThumbnailMode == "local" {
return s.saveCoverLocal(filePath, coverData)
}
// 默认 base64 模式
return imageDataToBase64Thumb(coverData)
}
// saveCoverLocal 将封面图片保存到本地文件系统,返回可访问的路径
func (s *BookLocalScraper) saveCoverLocal(filePath string, coverData []byte) string {
// 生成缩略图数据
img, _, err := image.Decode(bytes.NewReader(coverData))
if err != nil {
// 解码失败时直接保存原始数据
return s.writeLocalFile(filePath, coverData)
}
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil {
return ""
}
return s.writeLocalFile(filePath, buf.Bytes())
}
// writeLocalFile 将数据写入本地文件,返回内部访问路径
func (s *BookLocalScraper) writeLocalFile(filePath string, data []byte) string {
safeFileName := strings.ReplaceAll(strings.TrimPrefix(filePath, "/"), "/", "_") + ".jpg"
localDir := s.ThumbnailPath
if !filepath.IsAbs(localDir) {
if wd, err := os.Getwd(); err == nil {
localDir = filepath.Join(wd, localDir)
}
}
if err := os.MkdirAll(localDir, 0755); err != nil {
return ""
}
localFilePath := filepath.Join(localDir, safeFileName)
if err := os.WriteFile(localFilePath, data, 0644); err != nil {
return ""
}
thumbVFSPath := strings.TrimSuffix(s.ThumbnailPath, "/") + "/" + safeFileName
return buildInternalDownloadPath(thumbVFSPath)
}
// ── EPUB 封面提取 ─────────────────────────────────────────────────────────────
// epubContainer 解析 META-INF/container.xml
type epubContainer struct {
Rootfiles []struct {
FullPath string `xml:"full-path,attr"`
} `xml:"rootfiles>rootfile"`
}
// epubOPF 解析 OPF 文件,找封面 item
type epubOPF struct {
Metadata struct {
Metas []struct {
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
Manifest struct {
Items []struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
} `xml:"item"`
} `xml:"manifest"`
}
// extractEpubCoverData 从 EPUB(zip)数据中提取封面图片原始字节
func extractEpubCoverData(data []byte) []byte {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil
}
// 建立文件名→ZipFile 映射(忽略大小写)
fileMap := make(map[string]*zip.File, len(r.File))
for _, f := range r.File {
fileMap[strings.ToLower(f.Name)] = f
}
// 1. 尝试读取 META-INF/container.xml 找 OPF 路径
opfPath := ""
if cf, ok := fileMap["meta-inf/container.xml"]; ok {
if rc, err := cf.Open(); err == nil {
var container epubContainer
if xml.NewDecoder(rc).Decode(&container) == nil && len(container.Rootfiles) > 0 {
opfPath = container.Rootfiles[0].FullPath
}
rc.Close()
}
}
// OPF 所在目录(用于拼接相对路径)
opfDir := ""
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
opfDir = opfPath[:idx+1]
}
// 2. 从 OPF 中找封面 item ID
coverHref := ""
if opfPath != "" {
if of, ok := fileMap[strings.ToLower(opfPath)]; ok {
if rc, err := of.Open(); err == nil {
var opf epubOPF
if xml.NewDecoder(rc).Decode(&opf) == nil {
// 先找 <meta name="cover" content="..."/> 指向的 item id
coverItemID := ""
for _, m := range opf.Metadata.Metas {
if strings.EqualFold(m.Name, "cover") && m.Content != "" {
coverItemID = m.Content
break
}
}
// 在 manifest 中找对应 item
for _, item := range opf.Manifest.Items {
if coverItemID != "" && item.ID == coverItemID {
coverHref = opfDir + item.Href
break
}
// 如果没有指定 cover id,找第一个图片类型的 item
if coverItemID == "" && strings.Contains(strings.ToLower(item.MediaType), "image") {
// 优先选择 id 或 href 中包含 "cover" 的
if strings.Contains(strings.ToLower(item.ID), "cover") ||
strings.Contains(strings.ToLower(item.Href), "cover") {
coverHref = opfDir + item.Href
break
}
}
}
// 如果还没找到,取第一个图片
if coverHref == "" && coverItemID == "" {
for _, item := range opf.Manifest.Items {
if strings.Contains(strings.ToLower(item.MediaType), "image") {
coverHref = opfDir + item.Href
break
}
}
}
}
rc.Close()
}
}
}
// 3. 常见封面文件名候选(按优先级)
candidates := []string{}
if coverHref != "" {
candidates = append(candidates, strings.ToLower(coverHref))
}
candidates = append(candidates,
"cover.jpg", "cover.jpeg", "cover.png",
"images/cover.jpg", "images/cover.jpeg", "images/cover.png",
"oebps/cover.jpg", "oebps/images/cover.jpg",
"oebps/cover.jpeg", "oebps/images/cover.jpeg",
)
for _, name := range candidates {
if f, ok := fileMap[name]; ok {
if rc, err := f.Open(); err == nil {
imgData, readErr := io.ReadAll(rc)
rc.Close()
if readErr == nil && len(imgData) > 0 {
return imgData
}
}
}
}
return nil
}
// extractEpubCoverBase64 从 EPUB(zip)数据中提取封面图片,返回 data URI base64(向后兼容)
func extractEpubCoverBase64(data []byte) string {
coverData := extractEpubCoverData(data)
if len(coverData) == 0 {
return ""
}
return imageDataToBase64Thumb(coverData)
}
// ── PDF 封面提取 ──────────────────────────────────────────────────────────────
// extractPDFCoverData 从 PDF 数据中提取第一个内嵌 JPEG 图片流,返回原始字节
// PDF 内嵌图片通常以 JPEG 流存储(SOI marker: 0xFF 0xD8),直接扫描字节流提取
func extractPDFCoverData(data []byte) []byte {
// 扫描 JPEG SOI (0xFF 0xD8 0xFF) 标记
for i := 0; i < len(data)-2; i++ {
if data[i] == 0xFF && data[i+1] == 0xD8 && data[i+2] == 0xFF {
// 找对应的 EOI (0xFF 0xD9)
for j := i + 2; j < len(data)-1; j++ {
if data[j] == 0xFF && data[j+1] == 0xD9 {
jpegData := data[i : j+2]
// 验证是否为有效图片(至少 1KB,避免误匹配小缩略图)
if len(jpegData) > 1024 {
return jpegData
}
break
}
}
}
}
return nil
}
// extractPDFCoverBase64 从 PDF 数据中提取第一个内嵌 JPEG 图片流,返回 data URI base64(向后兼容)
func extractPDFCoverBase64(data []byte) string {
coverData := extractPDFCoverData(data)
if len(coverData) == 0 {
return ""
}
return imageDataToBase64Thumb(coverData)
}
// ── 公共辅助 ──────────────────────────────────────────────────────────────────
// imageDataToBase64Thumb 将图片字节解码并缩放为 300px 宽缩略图,返回 data URI base64
func imageDataToBase64Thumb(data []byte) string {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
// 解码失败时直接 base64 原始数据(可能是 PNG 等)
mimeType := detectImageMime(data)
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil {
return ""
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
// detectImageMime 根据文件头检测图片 MIME 类型
func detectImageMime(data []byte) string {
if len(data) < 4 {
return "image/jpeg"
}
switch {
case data[0] == 0xFF && data[1] == 0xD8:
return "image/jpeg"
case data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47:
return "image/png"
case data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46:
return "image/gif"
case data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46:
return "image/webp"
default:
return "image/jpeg"
}
}
// getFileExt 获取文件扩展名(含点号,小写)
func getFileExt(fileName string) string {
if idx := strings.LastIndex(fileName, "."); idx >= 0 {
return fileName[idx:]
}
return ""
}
// mimeTypeByBookExt 根据书籍文件扩展名返回 MIME 类型
func mimeTypeByBookExt(ext string) string {
switch ext {
case ".pdf":
return "application/pdf"
case ".epub":
return "application/epub+zip"
case ".mobi":
return "application/x-mobipocket-ebook"
case ".azw3":
return "application/vnd.amazon.ebook"
case ".txt":
return "text/plain"
case ".djvu":
return "image/vnd.djvu"
case ".cbz":
return "application/vnd.comicbook+zip"
case ".cbr":
return "application/vnd.comicbook-rar"
default:
return "application/octet-stream"
}
}
-317
View File
@@ -1,317 +0,0 @@
package scraper
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
const discogsDefaultBaseURL = "https://api.discogs.com"
// DiscogsScraper Discogs音乐刮削器
type DiscogsScraper struct {
Token string
BaseURL string // 可自定义的 Discogs API 地址
client *http.Client
}
// NewDiscogsScraper 创建Discogs刮削器
//
// 用户只需填写「域名 + API 前缀」,例如:
// 1. 留空:使用默认 https://api.discogs.com
// 2. 官方:api.discogs.com -> https://api.discogs.com
// 3. 反代:example.edgeone.run/api/discogs -> https://example.edgeone.run/api/discogs
// 4. 反代:example.edgeone.run/api/discogs/ -> https://example.edgeone.run/api/discogs(自动去尾斜杠)
//
// 后续会基于 BaseURL 直接拼接 /database/search、/releases/{id} 等具体路径。
func NewDiscogsScraper(token string, baseURL string) *DiscogsScraper {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
base = discogsDefaultBaseURL
}
// 协议头容错:用户没填 http/https 时自动补 https://
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
base = "https://" + base
}
// 再次去掉末尾多余的 /,避免后续拼接出 //database/search 这类双斜杠
base = strings.TrimRight(base, "/")
return &DiscogsScraper{
Token: token,
BaseURL: base,
client: &http.Client{Timeout: 15 * time.Second},
}
}
type discogsSearchResult struct {
Results []struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Year string `json:"year"`
Thumb string `json:"thumb"`
CoverImage string `json:"cover_image"`
Genre []string `json:"genre"`
Style []string `json:"style"`
ResourceURL string `json:"resource_url"`
} `json:"results"`
}
type discogsReleaseDetail struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
CoverImage string `json:"cover_image"`
Notes string `json:"notes"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
Genres []string `json:"genres"`
Styles []string `json:"styles"`
Tracklist []struct {
Position string `json:"position"`
Title string `json:"title"`
Duration string `json:"duration"`
} `json:"tracklist"`
Community struct {
Rating struct {
Average float32 `json:"average"`
} `json:"rating"`
} `json:"community"`
}
// parseMusicFileName 从音乐文件名中提取搜索关键词列表
// 规则:去掉扩展名后,按" - "或"-"分割,返回各部分作为候选搜索词
// 例如:
//
// "周杰伦 - 七里香.mp3" -> ["周杰伦 - 七里香", "周杰伦", "七里香"]
// "Coldplay-Yellow.flac" -> ["Coldplay-Yellow", "Coldplay", "Yellow"]
// "七里香.mp3" -> ["七里香"]
func parseMusicFileName(fileName string) []string {
// 去掉扩展名
if idx := strings.LastIndex(fileName, "."); idx > 0 {
ext := strings.ToLower(fileName[idx:])
if len(ext) <= 5 {
fileName = fileName[:idx]
}
}
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return nil
}
var queries []string
// 完整文件名作为第一候选
queries = append(queries, fileName)
// 按" - "(带空格)分割
parts := strings.SplitN(fileName, " - ", 2)
if len(parts) == 2 {
before := strings.TrimSpace(parts[0])
after := strings.TrimSpace(parts[1])
if before != "" {
queries = append(queries, before)
}
if after != "" {
queries = append(queries, after)
}
} else {
// 按"-"(不带空格)分割
parts = strings.SplitN(fileName, "-", 2)
if len(parts) == 2 {
before := strings.TrimSpace(parts[0])
after := strings.TrimSpace(parts[1])
if before != "" {
queries = append(queries, before)
}
if after != "" {
queries = append(queries, after)
}
}
}
return queries
}
// doDiscogsSearch 执行单次Discogs搜索
func (s *DiscogsScraper) doDiscogsSearch(query string) (*discogsSearchResult, error) {
searchURL := fmt.Sprintf("%s/database/search?q=%s&type=release&token=%s",
s.BaseURL, url.QueryEscape(query), s.Token)
req, _ := http.NewRequest("GET", searchURL, nil)
req.Header.Set("User-Agent", "OpenList/4.0 +https://github.com/OpenListTeam/OpenList")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("Discogs搜索请求失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result discogsSearchResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("Discogs搜索结果解析失败: %w", err)
}
return &result, nil
}
// buildMusicQuery 根据 MediaItem 已有字段拼接 Discogs 搜索词
// 优先级:专辑名 > ScrapedName > 文件名解析
// 若有歌手或年份信息,则附加到搜索词中以提高精准度
func buildMusicQuery(base string, item *model.MediaItem) string {
q := base
// 附加歌手
if item.AlbumArtist != "" {
q = item.AlbumArtist + " " + q
}
// 附加年份(取 ReleaseDate 前4位)
if len(item.ReleaseDate) >= 4 {
q = q + " " + item.ReleaseDate[:4]
}
return strings.TrimSpace(q)
}
// ScrapeMusic 刮削音乐/专辑信息
func (s *DiscogsScraper) ScrapeMusic(item *model.MediaItem) error {
if s.Token == "" {
return fmt.Errorf("Discogs Token 未配置")
}
// 构建候选搜索词列表
var queries []string
if item.AlbumName != "" {
queries = append(queries, buildMusicQuery(item.AlbumName, item))
// 同时保留不带附加信息的纯专辑名作为降级候选
if item.AlbumArtist != "" || len(item.ReleaseDate) >= 4 {
queries = append(queries, item.AlbumName)
}
} else if item.ScrapedName != "" {
queries = append(queries, buildMusicQuery(item.ScrapedName, item))
if item.AlbumArtist != "" || len(item.ReleaseDate) >= 4 {
queries = append(queries, item.ScrapedName)
}
} else {
// 从文件名解析,按"-"分割分别搜索
baseQueries := parseMusicFileName(item.FileName)
for _, bq := range baseQueries {
enhanced := buildMusicQuery(bq, item)
if enhanced != bq {
// 先放带附加信息的精准词,再放原始词作为降级
queries = append(queries, enhanced)
}
queries = append(queries, bq)
}
}
if len(queries) == 0 {
return fmt.Errorf("无法从文件名中提取有效搜索词")
}
// 依次尝试各候选词,找到结果即停止(模糊匹配降级)
var searchResult *discogsSearchResult
var lastQuery string
for _, q := range queries {
result, err := s.doDiscogsSearch(q)
if err != nil {
return err
}
lastQuery = q
if len(result.Results) > 0 {
searchResult = result
break
}
}
if searchResult == nil || len(searchResult.Results) == 0 {
return fmt.Errorf("Discogs未找到匹配结果: %s", lastQuery)
}
first := searchResult.Results[0]
// 获取详情
detailURL := fmt.Sprintf("%s/releases/%d?token=%s", s.BaseURL, first.ID, s.Token)
detailReq, _ := http.NewRequest("GET", detailURL, nil)
detailReq.Header.Set("User-Agent", "OpenList/4.0 +https://github.com/OpenListTeam/OpenList")
detailResp, err := s.client.Do(detailReq)
if err != nil {
return fmt.Errorf("Discogs详情请求失败: %w", err)
}
defer detailResp.Body.Close()
detailBody, _ := io.ReadAll(detailResp.Body)
var detail discogsReleaseDetail
if err := json.Unmarshal(detailBody, &detail); err != nil {
return fmt.Errorf("Discogs详情解析失败: %w", err)
}
// 填充字段:已有值的字段不覆盖,仅补充空缺
// Discogs 的 title 就是专辑名(不是 "Artist - Album" 格式)
if detail.Title != "" {
if item.AlbumName == "" {
item.AlbumName = detail.Title
}
if item.ScrapedName == "" {
item.ScrapedName = detail.Title
}
}
if item.ReleaseDate == "" {
if detail.Year > 0 {
item.ReleaseDate = fmt.Sprintf("%d-01-01", detail.Year)
} else if first.Year != "" {
item.ReleaseDate = first.Year + "-01-01"
}
}
if item.Rating == 0 {
item.Rating = detail.Community.Rating.Average
}
if item.Plot == "" {
item.Plot = detail.Notes
}
if item.ExternalID == "" {
item.ExternalID = fmt.Sprintf("discogs:%d", detail.ID)
}
// 封面(存储 URL,前端直接展示)
if item.Cover == "" {
if detail.CoverImage != "" {
item.Cover = detail.CoverImage
} else if first.CoverImage != "" {
item.Cover = first.CoverImage
} else if first.Thumb != "" {
item.Cover = first.Thumb
}
}
// 类型
if item.Genre == "" {
genres := append(detail.Genres, detail.Styles...)
item.Genre = strings.Join(genres, ",")
}
// 艺术家:仅在 Authors 和 AlbumArtist 均为空时才填充(ID3已有值则保留)
artists := make([]string, 0, len(detail.Artists))
for _, a := range detail.Artists {
artists = append(artists, a.Name)
}
if len(artists) > 0 {
if item.Authors == "" {
authorsJSON, _ := json.Marshal(artists)
item.Authors = string(authorsJSON)
}
if item.AlbumArtist == "" {
item.AlbumArtist = artists[0]
}
}
now := time.Now()
item.ScrapedAt = &now
return nil
}
-483
View File
@@ -1,483 +0,0 @@
package scraper
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"golang.org/x/net/html"
)
const (
doubanSearchURL = "https://www.douban.com/search"
doubanBookCat = "1001"
doubanBase = "https://book.douban.com/"
)
var (
doubanBookURLPattern = regexp.MustCompile(`.*/subject/(\d+)/?`)
doubanDatePattern = regexp.MustCompile(`(\d{4})-(\d+)`)
doubanDefaultHeaders = map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": doubanBase,
}
)
// DoubanScraper 豆瓣书籍刮削器(移植自 NewDouban.py)
type DoubanScraper struct {
client *http.Client
// ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件)
ThumbnailMode string
// ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail"
ThumbnailPath string
}
// NewDoubanScraper 创建豆瓣刮削器
func NewDoubanScraper() *DoubanScraper {
return &DoubanScraper{
client: &http.Client{Timeout: 20 * time.Second},
ThumbnailMode: "base64",
ThumbnailPath: "/.thumbnail",
}
}
// NewDoubanScraperWithConfig 创建带完整配置的豆瓣刮削器
func NewDoubanScraperWithConfig(thumbnailMode, thumbnailPath string) *DoubanScraper {
if thumbnailMode == "" {
thumbnailMode = "base64"
}
if thumbnailPath == "" {
thumbnailPath = "/.thumbnail"
}
return &DoubanScraper{
client: &http.Client{Timeout: 20 * time.Second},
ThumbnailMode: thumbnailMode,
ThumbnailPath: thumbnailPath,
}
}
// ScrapeBook 刮削书籍信息
func (s *DoubanScraper) ScrapeBook(item *model.MediaItem) error {
query := item.ScrapedName
if query == "" {
query = strings.TrimSuffix(item.FileName, strings.ToLower(item.FileName[strings.LastIndex(item.FileName, "."):]))
}
// 搜索书籍URL列表
bookURLs, err := s.searchBookURLs(query)
if err != nil {
return fmt.Errorf("豆瓣搜索失败: %w", err)
}
if len(bookURLs) == 0 {
return fmt.Errorf("豆瓣未找到匹配书籍: %s", query)
}
// 取第一个结果
bookDetail, err := s.loadBookDetail(bookURLs[0])
if err != nil {
return fmt.Errorf("豆瓣获取书籍详情失败: %w", err)
}
// 填充字段(仅填空字段,已有值不覆盖)
if item.ScrapedName == "" && bookDetail.Title != "" {
item.ScrapedName = bookDetail.Title
}
if item.Plot == "" {
item.Plot = bookDetail.Description
}
// 仅在豆瓣返回了有效封面图片URL且当前 item 还没有封面时才下载/覆盖
if item.Cover == "" && bookDetail.Cover != "" {
if cover := s.downloadAndStoreCover(item.FolderPath, bookDetail.Cover); cover != "" {
item.Cover = cover
} else {
// 下载失败时保留原 URL(降级)
item.Cover = bookDetail.Cover
}
}
if item.Rating == 0 {
item.Rating = bookDetail.Rating
}
if item.ReleaseDate == "" {
item.ReleaseDate = bookDetail.PublishedDate
}
if item.Publisher == "" {
item.Publisher = bookDetail.Publisher
}
if item.ISBN == "" {
item.ISBN = bookDetail.ISBN
}
if item.ExternalID == "" {
item.ExternalID = "douban:" + bookDetail.ID
}
if item.Authors == "" && len(bookDetail.Authors) > 0 {
authorsJSON, _ := json.Marshal(bookDetail.Authors)
item.Authors = string(authorsJSON)
}
if item.Genre == "" && len(bookDetail.Tags) > 0 {
item.Genre = strings.Join(bookDetail.Tags, ",")
}
now := time.Now()
item.ScrapedAt = &now
return nil
}
type doubanBookDetail struct {
ID string
Title string
Authors []string
Publisher string
PublishedDate string
Cover string
Rating float32
Description string
Tags []string
ISBN string
}
// searchBookURLs 搜索豆瓣书籍URL列表
func (s *DoubanScraper) searchBookURLs(query string) ([]string, error) {
searchURL := fmt.Sprintf("%s?cat=%s&q=%s", doubanSearchURL, doubanBookCat, url.QueryEscape(query))
body, err := s.doGet(searchURL)
if err != nil {
return nil, err
}
doc, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
return nil, err
}
var bookURLs []string
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
for _, attr := range n.Attr {
if attr.Key == "class" && attr.Val == "nbg" {
for _, a := range n.Attr {
if a.Key == "href" {
parsed := s.calcURL(a.Val)
if parsed != "" && len(bookURLs) < 5 {
bookURLs = append(bookURLs, parsed)
}
}
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}
traverse(doc)
return bookURLs, nil
}
// calcURL 解析豆瓣搜索结果中的真实URL
func (s *DoubanScraper) calcURL(href string) string {
parsed, err := url.Parse(href)
if err != nil {
return ""
}
query := parsed.Query()
rawURL := query.Get("url")
if rawURL == "" {
return ""
}
decoded, err := url.QueryUnescape(rawURL)
if err != nil {
return ""
}
if doubanBookURLPattern.MatchString(decoded) {
return decoded
}
return ""
}
// loadBookDetail 加载书籍详情页
func (s *DoubanScraper) loadBookDetail(bookURL string) (*doubanBookDetail, error) {
body, err := s.doGet(bookURL)
if err != nil {
return nil, err
}
return s.parseBookHTML(bookURL, string(body))
}
// parseBookHTML 解析书籍HTML(移植自 DoubanBookHtmlParser.parse_book)
func (s *DoubanScraper) parseBookHTML(bookURL, content string) (*doubanBookDetail, error) {
doc, err := html.Parse(strings.NewReader(content))
if err != nil {
return nil, err
}
detail := &doubanBookDetail{}
// 提取豆瓣ID
if m := doubanBookURLPattern.FindStringSubmatch(bookURL); len(m) > 1 {
detail.ID = m[1]
}
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "span":
// 标题
if getAttr(n, "property") == "v:itemreviewed" {
detail.Title = getTextContent(n)
}
// 元数据字段
if getAttr(n, "class") == "pl" {
text := getTextContent(n)
tail := getNextText(n)
switch {
case strings.HasPrefix(text, "作者") || strings.HasPrefix(text, "译者"):
// 作者通过链接提取
collectAuthors(n, &detail.Authors)
case strings.HasPrefix(text, "出版社"):
detail.Publisher = strings.TrimSpace(tail)
case strings.HasPrefix(text, "出版年"):
detail.PublishedDate = parseDoubanDate(strings.TrimSpace(tail))
case strings.HasPrefix(text, "ISBN"):
detail.ISBN = strings.TrimSpace(tail)
case strings.HasPrefix(text, "副标题"):
if detail.Title != "" {
detail.Title += ":" + strings.TrimSpace(tail)
}
}
}
// 评分
if getAttr(n, "property") == "v:average" {
ratingStr := getTextContent(n)
var rating float32
fmt.Sscanf(ratingStr, "%f", &rating)
detail.Rating = rating / 2
}
case "img":
// 封面图片:<img class="nbg" src="..." />
if getAttr(n, "class") == "nbg" {
src := getAttr(n, "src")
if src != "" && !strings.HasSuffix(src, "update_image") {
detail.Cover = src
}
}
// 备用:#mainpic 下的 img
if getAttr(n, "id") == "mainpic" || (n.Parent != nil && getAttr(n.Parent, "id") == "mainpic") {
src := getAttr(n, "src")
if src != "" && detail.Cover == "" {
detail.Cover = src
}
}
case "a":
// 标签(原封面逻辑移除,改为从img获取)
cls := getAttr(n, "class")
if strings.Contains(cls, "tag") {
tag := getTextContent(n)
if tag != "" {
detail.Tags = append(detail.Tags, tag)
}
}
case "div":
// 简介
if getAttr(n, "id") == "link-report" {
detail.Description = extractIntroText(n)
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}
traverse(doc)
return detail, nil
}
// HTML辅助函数
func getAttr(n *html.Node, key string) string {
for _, attr := range n.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
func getTextContent(n *html.Node) string {
var sb strings.Builder
var walk func(*html.Node)
walk = func(node *html.Node) {
if node.Type == html.TextNode {
sb.WriteString(node.Data)
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(sb.String())
}
func getNextText(n *html.Node) string {
if n.NextSibling != nil {
if n.NextSibling.Type == html.TextNode {
return strings.TrimSpace(n.NextSibling.Data)
}
return getTextContent(n.NextSibling)
}
return ""
}
func collectAuthors(n *html.Node, authors *[]string) {
var walk func(*html.Node)
walk = func(node *html.Node) {
if node.Type == html.ElementNode && node.Data == "a" {
href := getAttr(node, "href")
if strings.Contains(href, "/author") || strings.Contains(href, "/search") {
name := getTextContent(node)
if name != "" {
*authors = append(*authors, name)
}
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
// 从父节点开始找
if n.Parent != nil {
walk(n.Parent)
}
}
func extractIntroText(n *html.Node) string {
var sb strings.Builder
var walk func(*html.Node)
walk = func(node *html.Node) {
if node.Type == html.ElementNode && node.Data == "div" {
if getAttr(node, "class") == "intro" {
sb.WriteString(getTextContent(node))
return
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(sb.String())
}
func parseDoubanDate(dateStr string) string {
if dateStr == "" {
return ""
}
if m := doubanDatePattern.FindStringSubmatch(dateStr); len(m) > 2 {
return fmt.Sprintf("%s-%s-01", m[1], m[2])
}
return dateStr
}
// downloadAndStoreCover 下载封面图片并根据 ThumbnailMode 存储
// 失败时返回空字符串
func (s *DoubanScraper) downloadAndStoreCover(filePath, imgURL string) string {
data, mimeType := s.downloadImage(imgURL)
if len(data) == 0 {
return ""
}
if s.ThumbnailMode == "local" {
// 本地存储模式
localScraper := NewBookLocalScraperWithConfig(s.ThumbnailMode, s.ThumbnailPath)
return localScraper.saveCoverLocal(filePath, data)
}
// Base64 模式(默认)
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil {
return ""
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
// downloadCoverAsBase64 下载封面图片并转为 data URI Base64 字符串(向后兼容)
// 失败时返回空字符串
func (s *DoubanScraper) downloadCoverAsBase64(imgURL string) string {
data, mimeType := s.downloadImage(imgURL)
if len(data) == 0 {
return ""
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil {
return ""
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
// downloadImage 下载图片,返回原始字节和 MIME 类型
func (s *DoubanScraper) downloadImage(imgURL string) ([]byte, string) {
req, err := http.NewRequest("GET", imgURL, nil)
if err != nil {
return nil, ""
}
for k, v := range doubanDefaultHeaders {
req.Header.Set(k, v)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, ""
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, ""
}
data, err := io.ReadAll(resp.Body)
if err != nil || len(data) == 0 {
return nil, ""
}
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "image/jpeg"
}
return data, mimeType
}
// doGet 发送GET请求
func (s *DoubanScraper) doGet(reqURL string) ([]byte, error) {
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
for k, v := range doubanDefaultHeaders {
req.Header.Set(k, v)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, reqURL)
}
return io.ReadAll(resp.Body)
}
-259
View File
@@ -1,259 +0,0 @@
package scraper
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/rwcarlsen/goexif/exif"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
// ImageScraper 图片刮削器
// 从图片 EXIF 中提取元数据,并生成缩略图写入 Cover 字段。
type ImageScraper struct {
// StoreThumbnail 为 true 时生成缩略图写入 Cover;
// 为 false 时直接用文件路径作为 Cover,节省数据库空间。
StoreThumbnail bool
// ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件)
ThumbnailMode string
// ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail"
ThumbnailPath string
}
// NewImageScraper 创建图片刮削器
// storeThumbnail 为 true 时生成缩略图写入 Cover。
func NewImageScraper(storeThumbnail bool) *ImageScraper {
return &ImageScraper{
StoreThumbnail: storeThumbnail,
ThumbnailMode: "base64",
ThumbnailPath: "/.thumbnail",
}
}
// NewImageScraperWithConfig 创建带完整配置的图片刮削器
func NewImageScraperWithConfig(storeThumbnail bool, thumbnailMode, thumbnailPath string) *ImageScraper {
if thumbnailMode == "" {
thumbnailMode = "base64"
}
if thumbnailPath == "" {
thumbnailPath = "/.thumbnail"
}
return &ImageScraper{
StoreThumbnail: storeThumbnail,
ThumbnailMode: thumbnailMode,
ThumbnailPath: thumbnailPath,
}
}
// ScrapeImage 刮削图片信息
// reader 为图片文件内容流,必须传入有效流才能生成缩略图。
// 当 reader 不为 nil 时:
// - 从 EXIF 读取拍摄时间、GPS 地点、相机型号+参数、评分
// - 生成 300px 宽缩略图,根据 ThumbnailMode 存储为 Base64 或本地文件
//
// 注意:绝不将文件路径作为 cover,reader 为 nil 或缩略图生成失败时 cover 保持为空。
func (s *ImageScraper) ScrapeImage(item *model.MediaItem, reader io.Reader) error {
// 去掉扩展名作为展示名(如果尚未设置)
if item.ScrapedName == "" {
item.ScrapedName = trimExt(item.FileName)
}
if reader == nil {
// 无文件流时无法生成缩略图,cover 保持为空,等待下次重试
now := time.Now()
item.ScrapedAt = &now
return nil
}
// 将流读入内存(EXIF 解析和图像解码都需要完整数据)
data, err := io.ReadAll(reader)
if err != nil || len(data) == 0 {
// 读取失败时 cover 保持为空
now := time.Now()
item.ScrapedAt = &now
return nil
}
// ── 1. 解析 EXIF ──────────────────────────────────────────────
s.parseEXIF(item, bytes.NewReader(data))
// ── 2. 生成缩略图并存储 ──────────────────────────────────────
if s.ThumbnailMode == "local" {
// 本地存储模式:将缩略图保存到指定目录,Cover 存储本地路径
if localPath := s.saveThumbnailLocal(item.FolderPath, data); localPath != "" {
item.Cover = localPath
}
// 本地存储失败时 cover 保持为空,不降级为文件路径
} else {
// Base64 模式(默认):生成缩略图 Base64 写入 Cover
if thumb := s.generateThumbnail(data); thumb != "" {
item.Cover = thumb
}
// 缩略图生成失败时 cover 保持为空,不降级为文件路径
}
now := time.Now()
item.ScrapedAt = &now
return nil
}
// saveThumbnailLocal 将缩略图保存到本地文件系统,返回可访问的路径
// 文件保存在 ThumbnailPath 目录下,文件名为原文件路径的 hash + .jpg
func (s *ImageScraper) saveThumbnailLocal(filePath string, data []byte) string {
// 生成缩略图数据
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return ""
}
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 75}); err != nil {
return ""
}
// 构建本地存储路径:ThumbnailPath/原文件路径.jpg
// 将文件路径中的 / 替换为 _ 避免目录嵌套问题
safeFileName := strings.ReplaceAll(strings.TrimPrefix(filePath, "/"), "/", "_") + ".jpg"
localDir := s.ThumbnailPath
if !filepath.IsAbs(localDir) {
// 相对路径转为绝对路径(相对于工作目录)
if wd, err := os.Getwd(); err == nil {
localDir = filepath.Join(wd, localDir)
}
}
// 确保目录存在
if err := os.MkdirAll(localDir, 0755); err != nil {
return ""
}
localFilePath := filepath.Join(localDir, safeFileName)
if err := os.WriteFile(localFilePath, buf.Bytes(), 0644); err != nil {
return ""
}
// 返回内部访问路径(通过 /d/ 前缀访问)
// 缩略图路径格式:ThumbnailPath/safeFileName
thumbVFSPath := strings.TrimSuffix(s.ThumbnailPath, "/") + "/" + safeFileName
return buildInternalDownloadPath(thumbVFSPath)
}
// parseEXIF 从图片数据中解析 EXIF 元数据并写入 item
func (s *ImageScraper) parseEXIF(item *model.MediaItem, r io.Reader) {
x, err := exif.Decode(r)
if err != nil {
return
}
// ── 拍摄时间 → ReleaseDate ────────────────────────────────────
if item.ReleaseDate == "" {
if t, err := x.DateTime(); err == nil {
item.ReleaseDate = t.Format("2006-01-02")
}
}
// ── GPS 地点 → Authors(借用作者字段存储地点信息)────────────
if item.Authors == "" {
if lat, lon, err := x.LatLong(); err == nil {
item.Authors = fmt.Sprintf(`["GPS: %.6f, %.6f"]`, lat, lon)
}
}
// ── 相机型号 + 参数 → Genre ───────────────────────────────────
if item.Genre == "" {
var parts []string
if make, err := x.Get(exif.Make); err == nil {
if v, err := make.StringVal(); err == nil && v != "" {
parts = append(parts, strings.TrimSpace(v))
}
}
if model_, err := x.Get(exif.Model); err == nil {
if v, err := model_.StringVal(); err == nil && v != "" {
parts = append(parts, strings.TrimSpace(v))
}
}
if fnum, err := x.Get(exif.FNumber); err == nil {
if num, den, err := fnum.Rat2(0); err == nil && den != 0 {
parts = append(parts, fmt.Sprintf("f/%.1f", float64(num)/float64(den)))
}
}
if exp, err := x.Get(exif.ExposureTime); err == nil {
if num, den, err := exp.Rat2(0); err == nil && den != 0 {
if num == 1 {
parts = append(parts, fmt.Sprintf("1/%ds", den))
} else {
parts = append(parts, fmt.Sprintf("%d/%ds", num, den))
}
}
}
if iso, err := x.Get(exif.ISOSpeedRatings); err == nil {
if v, err := iso.Int(0); err == nil {
parts = append(parts, fmt.Sprintf("ISO%d", v))
}
}
if focal, err := x.Get(exif.FocalLength); err == nil {
if num, den, err := focal.Rat2(0); err == nil && den != 0 {
parts = append(parts, fmt.Sprintf("%.0fmm", float64(num)/float64(den)))
}
}
if len(parts) > 0 {
item.Genre = strings.Join(parts, ",")
}
}
// ── EXIF Rating(0-5 星)→ Rating(0-10 分)──────────────────
// Windows 资源管理器、Lightroom、Darktable 等软件会写入此标签
if item.Rating == 0 {
if ratingTag, err := x.Get(exif.FieldName("Rating")); err == nil {
if v, err := ratingTag.Int(0); err == nil && v > 0 {
// 1-5 星映射到 2-10 分
item.Rating = float32(v) * 2
}
}
}
}
// generateThumbnail 将图片数据缩放为 300px 宽缩略图,返回 data URI Base64 字符串
func (s *ImageScraper) generateThumbnail(data []byte) string {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return ""
}
// 缩放到宽度 300px,高度等比
thumb := imaging.Resize(img, 300, 0, imaging.Lanczos)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 75}); err != nil {
return ""
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
// buildInternalDownloadPath 将 VFS 文件路径转为内部代理下载路径
// 格式:/d/path/to/file(前端通过此路径访问文件内容)
func buildInternalDownloadPath(filePath string) string {
if !strings.HasPrefix(filePath, "/") {
filePath = "/" + filePath
}
return "/d" + filePath
}
// trimExt 去掉文件名的扩展名
func trimExt(fileName string) string {
if idx := strings.LastIndex(fileName, "."); idx > 0 {
return fileName[:idx]
}
return fileName
}
File diff suppressed because it is too large Load Diff
-113
View File
@@ -1,113 +0,0 @@
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"`
}
-2
View File
@@ -13,8 +13,6 @@ const (
S3
FTP
TRAFFIC
MEDIA
TRANSCODE
)
const (
-434
View File
@@ -1,434 +0,0 @@
package transcode
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// SegmentInfo 单个切片元数据
type SegmentInfo struct {
Seq int `json:"seq"`
Duration float64 `json:"duration"`
Size int64 `json:"size"`
Path string `json:"-"` // 本地实际文件路径
}
// JobCache 单个 Job 的切片缓存
type JobCache struct {
JobID string
Profile string
BaseDir string
mu sync.RWMutex
segments map[int]*SegmentInfo
final bool // 是否所有切片都到齐
waiters map[int][]chan struct{} // seq -> 等待这一片的 channel 列表
}
// Cache 全局切片缓存
type Cache struct {
rootDir string
maxBytes int64
mu sync.RWMutex
jobs map[string]map[string]*JobCache // jobID -> profile -> cache
}
func NewCache(rootDir string, maxGB int64) *Cache {
if maxGB <= 0 {
maxGB = 20
}
return &Cache{
rootDir: rootDir,
maxBytes: maxGB * 1024 * 1024 * 1024,
jobs: make(map[string]map[string]*JobCache),
}
}
// Init 创建根目录
func (c *Cache) Init() error {
return os.MkdirAll(c.rootDir, 0o755)
}
// getOrCreate 获取或创建 (job, profile) 的 cache
func (c *Cache) getOrCreate(jobID, profile string) (*JobCache, error) {
c.mu.Lock()
defer c.mu.Unlock()
pmap, ok := c.jobs[jobID]
if !ok {
pmap = make(map[string]*JobCache)
c.jobs[jobID] = pmap
}
jc, ok := pmap[profile]
if !ok {
jc = &JobCache{
JobID: jobID,
Profile: profile,
BaseDir: filepath.Join(c.rootDir, jobID, profile),
segments: make(map[int]*SegmentInfo),
waiters: make(map[int][]chan struct{}),
}
if err := os.MkdirAll(jc.BaseDir, 0o755); err != nil {
return nil, err
}
pmap[profile] = jc
}
return jc, nil
}
// PutSegment 写入一个切片,并通知所有等待者
func (c *Cache) PutSegment(jobID, profile string, seq int, duration float64, body io.Reader, isFinal bool) (*SegmentInfo, error) {
jc, err := c.getOrCreate(jobID, profile)
if err != nil {
return nil, err
}
fname := fmt.Sprintf("seg-%d.ts", seq)
fpath := filepath.Join(jc.BaseDir, fname)
tmpPath := fpath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return nil, err
}
n, err := io.Copy(f, body)
cerr := f.Close()
if err != nil {
os.Remove(tmpPath)
return nil, err
}
if cerr != nil {
os.Remove(tmpPath)
return nil, cerr
}
if err := os.Rename(tmpPath, fpath); err != nil {
os.Remove(tmpPath)
return nil, err
}
info := &SegmentInfo{Seq: seq, Duration: duration, Size: n, Path: fpath}
jc.mu.Lock()
jc.segments[seq] = info
if isFinal {
jc.final = true
}
// 通知所有等待 <= seq 的 waiter
var toClose []chan struct{}
for s, chs := range jc.waiters {
if s <= seq {
toClose = append(toClose, chs...)
delete(jc.waiters, s)
}
}
jc.mu.Unlock()
for _, ch := range toClose {
close(ch)
}
return info, nil
}
// GetSegment 读取一个切片(不等待)
func (c *Cache) GetSegment(jobID, profile string, seq int) (*SegmentInfo, error) {
c.mu.RLock()
pmap, ok := c.jobs[jobID]
c.mu.RUnlock()
if !ok {
return nil, errors.New("job not found")
}
jc, ok := pmap[profile]
if !ok {
return nil, errors.New("profile not found")
}
jc.mu.RLock()
defer jc.mu.RUnlock()
info, ok := jc.segments[seq]
if !ok {
return nil, errors.New("segment not found")
}
return info, nil
}
// WaitForSegment 等待某个 seq 切片就绪,timeout 后返回 nil
func (c *Cache) WaitForSegment(jobID, profile string, seq int, timeout time.Duration) (*SegmentInfo, error) {
jc, err := c.getOrCreate(jobID, profile)
if err != nil {
return nil, err
}
jc.mu.Lock()
if info, ok := jc.segments[seq]; ok {
jc.mu.Unlock()
return info, nil
}
if jc.final {
jc.mu.Unlock()
return nil, errors.New("segment never produced")
}
ch := make(chan struct{})
jc.waiters[seq] = append(jc.waiters[seq], ch)
jc.mu.Unlock()
select {
case <-ch:
jc.mu.RLock()
info := jc.segments[seq]
jc.mu.RUnlock()
if info == nil {
return nil, errors.New("segment not produced")
}
return info, nil
case <-time.After(timeout):
// 【内存泄漏修复】超时后必须从 waiters map 中移除自己的 channel,
// 否则 channel 会永远留在 map 中无法被 GC
jc.mu.Lock()
if chs, ok := jc.waiters[seq]; ok {
for i, w := range chs {
if w == ch {
jc.waiters[seq] = append(chs[:i], chs[i+1:]...)
break
}
}
if len(jc.waiters[seq]) == 0 {
delete(jc.waiters, seq)
}
}
jc.mu.Unlock()
return nil, errors.New("timeout waiting segment")
}
}
// SegmentCount 返回 (jobID, profile) 当前已生成的切片数量。
// 用于 TCPlaylist 在首次返回前等待至少若干切片就绪,避免 playlist
// 中只有 seg-0 导致 HLS.js 反复请求填充缓冲。
func (c *Cache) SegmentCount(jobID, profile string) int {
c.mu.RLock()
pmap, ok := c.jobs[jobID]
c.mu.RUnlock()
if !ok {
return 0
}
jc, ok := pmap[profile]
if !ok {
return 0
}
jc.mu.RLock()
defer jc.mu.RUnlock()
return len(jc.segments)
}
// BuildPlaylist 生成 HLS playlist (m3u8)
// 【重要】当 totalDuration > 0 时,会根据总时长预先生成完整切片列表(VOD 模式),
// 即使切片还未生成,URL 也会出现在 playlist 中。HLS.js 请求未生成切片时
// 会阻塞等待(见 TCSegment 的 WaitForSegment)。这样可以让 HLS.js 知道
// 视频总时长,避免:
// 1. 时长显示错误(只显示已转码部分)
// 2. 拖动跳跃(playlist 增长导致切片序号重新对齐)
func (c *Cache) BuildPlaylist(jobID, profile string, segDur int, completed bool, baseURL string, totalDuration float64) string {
c.mu.RLock()
pmap, ok := c.jobs[jobID]
c.mu.RUnlock()
if !ok {
return ""
}
jc, ok := pmap[profile]
if !ok {
return ""
}
jc.mu.RLock()
defer jc.mu.RUnlock()
var b strings.Builder
b.WriteString("#EXTM3U\n")
b.WriteString("#EXT-X-VERSION:3\n")
b.WriteString("#EXT-X-TARGETDURATION:" + strconv.Itoa(segDur) + "\n")
b.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n")
// 如果有总时长,按总时长生成完整切片列表
if totalDuration > 0 {
// VOD 模式:完整列表,HLS.js 知道总时长
b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
fSegDur := float64(segDur)
// 总切片数 = ceil(总时长 / 每片时长)
totalSegs := int(totalDuration / fSegDur)
lastDur := totalDuration - float64(totalSegs)*fSegDur
if lastDur > 0.01 {
totalSegs++
}
for i := 0; i < totalSegs; i++ {
dur := fSegDur
if i == totalSegs-1 && lastDur > 0.01 {
dur = lastDur
}
// 优先使用真实时长(如果已知)
if seg, ok := jc.segments[i]; ok && seg.Duration > 0 {
dur = seg.Duration
}
b.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", dur))
b.WriteString(fmt.Sprintf("%sseg-%d.ts\n", baseURL, i))
}
b.WriteString("#EXT-X-ENDLIST\n")
return b.String()
}
// fallback: 没有总时长时退回到原 EVENT 模式(保留旧行为)
seqs := make([]int, 0, len(jc.segments))
for s := range jc.segments {
seqs = append(seqs, s)
}
sort.Ints(seqs)
if jc.final || completed {
b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
} else {
b.WriteString("#EXT-X-PLAYLIST-TYPE:EVENT\n")
}
for _, s := range seqs {
seg := jc.segments[s]
b.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", seg.Duration))
b.WriteString(fmt.Sprintf("%sseg-%d.ts\n", baseURL, s))
}
if jc.final || completed {
b.WriteString("#EXT-X-ENDLIST\n")
}
return b.String()
}
// MasterPlaylist 多档位主 m3u8
func (c *Cache) MasterPlaylist(profiles []Profile, baseURL string) string {
var b strings.Builder
b.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n")
for _, p := range profiles {
bw, _ := parseBitrate(p.VideoBitrate)
ab, _ := parseBitrate(p.AudioBitrate)
// 【关键修复】根据实际编码动态生成 codec 字符串
// 错误的 codec 声明会导致 HLS.js 用错误参数初始化 SourceBuffer
// 然后 appendBuffer 时数据格式不匹配 → bufferAppendError 死循环
codecs := buildCodecString(p)
b.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,CODECS=\"%s\"\n", bw+ab, codecs))
b.WriteString(fmt.Sprintf("%s%s/playlist.m3u8\n", baseURL, p.Name))
}
return b.String()
}
// buildCodecString 根据 Profile 生成 HLS CODECS 属性字符串
// avc1.42E01E = H.264 Baseline Profile Level 3.0(兼容性最好)
// avc1.4D401E = H.264 Main Profile Level 3.0
// avc1.640028 = H.264 High Profile Level 4.0
// mp4a.40.2 = AAC-LC(HE-AAC v1 用 mp4a.40.5,HE-AAC v2 用 mp4a.40.29)
func buildCodecString(p Profile) string {
// 视频部分:默认 H.264 Main Profile Level 4.0,平衡兼容性与质量
video := "avc1.4D4028"
switch strings.ToLower(p.VideoCodec) {
case "hevc", "h265":
// HEVC Main Profile Level 4.1,部分 Safari/Edge 支持
video = "hvc1.1.6.L120.90"
case "av1":
video = "av01.0.05M.08"
}
// 音频部分:固定 AAC-LC(后端已强制转 AAC)
audio := "mp4a.40.2"
return video + "," + audio
}
func parseBitrate(s string) (int64, error) {
s = strings.TrimSpace(strings.ToLower(s))
if s == "" {
return 0, nil
}
mult := int64(1)
if strings.HasSuffix(s, "k") {
mult = 1000
s = strings.TrimSuffix(s, "k")
} else if strings.HasSuffix(s, "m") {
mult = 1_000_000
s = strings.TrimSuffix(s, "m")
}
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, err
}
return v * mult, nil
}
// Cleanup 删除某个 job 的所有切片,并关闭所有阻塞的 waiter goroutine
func (c *Cache) Cleanup(jobID string) {
c.mu.Lock()
pmap, exists := c.jobs[jobID]
delete(c.jobs, jobID)
c.mu.Unlock()
// 【内存泄漏修复】关闭所有 profile 下阻塞等待的 waiter channel,
// 避免 goroutine 永远挂起(每个 goroutine 至少 8KB 栈空间)
if exists {
for _, jc := range pmap {
jc.mu.Lock()
jc.final = true // 标记为 final,防止新 waiter 进入
for seq, chs := range jc.waiters {
for _, ch := range chs {
close(ch)
}
delete(jc.waiters, seq)
}
jc.mu.Unlock()
}
}
_ = os.RemoveAll(filepath.Join(c.rootDir, jobID))
}
// EnforceLRU 简易 LRU 容量控制:超过 maxBytes 时按目录修改时间从早到晚删除
func (c *Cache) EnforceLRU() error {
entries, err := os.ReadDir(c.rootDir)
if err != nil {
return err
}
type item struct {
dir string
size int64
mtime time.Time
}
var items []item
var total int64
for _, e := range entries {
if !e.IsDir() {
continue
}
full := filepath.Join(c.rootDir, e.Name())
size := dirSize(full)
fi, _ := os.Stat(full)
mt := time.Now()
if fi != nil {
mt = fi.ModTime()
}
items = append(items, item{full, size, mt})
total += size
}
if total <= c.maxBytes {
return nil
}
sort.Slice(items, func(i, j int) bool { return items[i].mtime.Before(items[j].mtime) })
for _, it := range items {
if total <= c.maxBytes {
break
}
_ = os.RemoveAll(it.dir)
total -= it.size
// 同步从内存里删
jobID := filepath.Base(it.dir)
c.mu.Lock()
delete(c.jobs, jobID)
c.mu.Unlock()
}
return nil
}
func dirSize(dir string) int64 {
var size int64
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err == nil && info != nil && !info.IsDir() {
size += info.Size()
}
return nil
})
return size
}
-520
View File
@@ -1,520 +0,0 @@
// Package transcode chunk_manager.go:智能分段转码调度器
//
// 设计目标:
// 1. 把视频按时间切成多个 chunk(默认每 60 秒一段),每个 chunk 独立用 ffmpeg 转码
// 2. 用户拖动到任意位置时,按需启动对应 chunk 的 ffmpeg,不再线性等待
// 3. 控制单 Job 最多并发 N 个 chunk ffmpeg,超出时 LRU 淘汰最久未访问的 chunk
// 4. 当前 chunk 接近末尾时自动预启动下一个 chunk,保证顺序播放无缝衔接
// 5. chunk 空闲超过阈值时 kill ffmpeg 释放 CPU,但保留已转出的切片文件
//
// 与 LocalRunner 的关系:
// - LocalRunner.runJob 不再直接启动一个完整的 ffmpeg,而是创建 ChunkSession 并阻塞等待
// - ChunkManager 是 ChunkSession 的"大脑",负责调度
// - TCSegment 收到切片请求时,调用 ChunkManager.EnsureChunkRunningForSeg(seq) 触发按需调度
package transcode
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// ChunkState chunk 当前状态
type ChunkState int
const (
ChunkIdle ChunkState = iota // 未启动
ChunkRunning // ffmpeg 正在转码
ChunkDone // ffmpeg 自然退出(chunk 内所有切片已转完)
ChunkFailed // ffmpeg 异常失败
ChunkKilled // 被 LRU 淘汰或空闲超时 kill
)
// Chunk 表示视频中的一个时间段,对应一段连续的切片序号
type Chunk struct {
Index int // chunk 序号(0 开始)
StartTime float64 // 起始秒
Duration float64 // 持续时长(最后一个 chunk 可能不足 chunkDur)
StartSeq int // 起始切片序号(包含)
EndSeq int // 结束切片序号(不包含)
State ChunkState
StartedAt time.Time
LastAccess time.Time
Error string
cancel context.CancelFunc // ffmpeg 进程的 cancel
doneCh chan struct{} // ffmpeg 进程结束时关闭
doneOnce sync.Once
}
// chunkDoneClose 关闭 doneCh(幂等)
func (c *Chunk) chunkDoneClose() {
c.doneOnce.Do(func() {
if c.doneCh != nil {
close(c.doneCh)
}
})
}
// ChunkSession 单个 (jobID, profile) 下的 chunk 调度上下文
type ChunkSession struct {
JobID string
Profile string
Job *Job
ProfDef Profile
// 静态参数
TotalDuration float64 // 视频总时长
SegDur int // 每片时长(秒)
ChunkDur int // 每个 chunk 时长(秒)
MaxConcurrent int // 最大并发 chunk 数
IdleSec int // chunk 空闲多久就 kill
Prefetch bool // 是否启用预读
mu sync.Mutex
chunks map[int]*Chunk // chunkIndex -> Chunk
// 给 LocalRunner 用:所有切片转完时关闭,让 runJob 能退出
allDoneCh chan struct{}
allDoneOnce sync.Once
// outDir / segPattern (供 ffmpeg 使用)
OutDir string
}
// allDoneClose 关闭 allDoneCh(幂等)
func (s *ChunkSession) allDoneClose() {
s.allDoneOnce.Do(func() {
close(s.allDoneCh)
})
}
// ChunkManager 全局 chunk 管理器(一个 Manager 一个)
type ChunkManager struct {
mgr *Manager
mu sync.Mutex
sessions map[string]*ChunkSession // key = jobID + ":" + profile
stopCh chan struct{}
}
// NewChunkManager 创建实例
func NewChunkManager(mgr *Manager) *ChunkManager {
return &ChunkManager{
mgr: mgr,
sessions: make(map[string]*ChunkSession),
stopCh: make(chan struct{}),
}
}
func sessionKey(jobID, profile string) string { return jobID + ":" + profile }
// StartSession 创建并启动一个 chunk session(由 LocalRunner.runJob 调用)
// 返回的 ChunkSession.allDoneCh 在所有切片都转完时关闭
func (cm *ChunkManager) StartSession(job *Job, profile Profile, outDir string) (*ChunkSession, error) {
totalDur := job.Probe.Duration
if totalDur <= 0 {
return nil, fmt.Errorf("chunk session requires probe.duration > 0")
}
segDur := setting.GetInt(conf.TranscodeSegmentDuration, 6)
if segDur <= 0 {
segDur = 6
}
chunkDur := setting.GetInt(conf.TranscodeChunkDurationSec, 60)
if chunkDur < segDur {
chunkDur = segDur * 10
}
// 让 chunkDur 是 segDur 的整数倍,保证切片对齐
if chunkDur%segDur != 0 {
chunkDur = (chunkDur / segDur) * segDur
if chunkDur < segDur {
chunkDur = segDur
}
}
maxConc := setting.GetInt(conf.TranscodeMaxChunkConcurrency, 2)
if maxConc <= 0 {
maxConc = 2
}
idleSec := setting.GetInt(conf.TranscodeChunkIdleSec, 60)
if idleSec <= 0 {
idleSec = 60
}
prefetch := setting.GetBool(conf.TranscodeChunkPrefetch)
s := &ChunkSession{
JobID: job.ID,
Profile: profile.Name,
Job: job,
ProfDef: profile,
TotalDuration: totalDur,
SegDur: segDur,
ChunkDur: chunkDur,
MaxConcurrent: maxConc,
IdleSec: idleSec,
Prefetch: prefetch,
chunks: make(map[int]*Chunk),
allDoneCh: make(chan struct{}),
OutDir: outDir,
}
// 预先按总时长生成所有 chunk 元数据(不启动)
segsPerChunk := chunkDur / segDur
totalSegs := int(totalDur) / segDur
if int(totalDur)%segDur > 0 {
totalSegs++
}
chunkCount := totalSegs / segsPerChunk
if totalSegs%segsPerChunk > 0 {
chunkCount++
}
for i := 0; i < chunkCount; i++ {
startSeq := i * segsPerChunk
endSeq := startSeq + segsPerChunk
if endSeq > totalSegs {
endSeq = totalSegs
}
startT := float64(startSeq * segDur)
dur := float64((endSeq - startSeq) * segDur)
// 最后一个 chunk 可能不足
if i == chunkCount-1 {
realDur := totalDur - startT
if realDur > 0 && realDur < dur {
dur = realDur
}
}
s.chunks[i] = &Chunk{
Index: i,
StartTime: startT,
Duration: dur,
StartSeq: startSeq,
EndSeq: endSeq,
State: ChunkIdle,
}
}
cm.mu.Lock()
cm.sessions[sessionKey(job.ID, profile.Name)] = s
cm.mu.Unlock()
// 立刻启动第一个 chunk(保证 playlist 首切片可用)
cm.ensureChunk(s, 0)
// 启动后台维护协程:空闲清理 + 完成检测
go cm.sessionLoop(s)
utils.Log.Infof("[transcode][chunk] session started job=%s profile=%s totalDur=%.1fs chunkDur=%ds chunks=%d maxConc=%d",
job.ID, profile.Name, totalDur, chunkDur, chunkCount, maxConc)
return s, nil
}
// EnsureChunkRunningForSeg 确保覆盖 seq 的 chunk 正在转码(由 TCSegment 触发)
// 返回所属 chunk index;如果 seq 越界返回 -1
func (cm *ChunkManager) EnsureChunkRunningForSeg(jobID, profile string, seq int) int {
cm.mu.Lock()
s, ok := cm.sessions[sessionKey(jobID, profile)]
cm.mu.Unlock()
if !ok {
utils.Log.Warnf("[transcode][chunk] EnsureChunk: session not found job=%s profile=%s seq=%d", jobID, profile, seq)
return -1
}
idx := cm.findChunkBySeq(s, seq)
if idx < 0 {
utils.Log.Warnf("[transcode][chunk] EnsureChunk: seq=%d out of range job=%s", seq, jobID)
return -1
}
cm.ensureChunk(s, idx)
return idx
}
// findChunkBySeq 按切片序号找到所属 chunk
func (cm *ChunkManager) findChunkBySeq(s *ChunkSession, seq int) int {
s.mu.Lock()
defer s.mu.Unlock()
for idx, c := range s.chunks {
if seq >= c.StartSeq && seq < c.EndSeq {
return idx
}
}
return -1
}
// ensureChunk 确保某个 chunk 处于 Running 状态(已运行/已完成则跳过)
// 同时受 MaxConcurrent 限制,超出时 LRU 淘汰最久未访问的 chunk
func (cm *ChunkManager) ensureChunk(s *ChunkSession, idx int) {
s.mu.Lock()
c, ok := s.chunks[idx]
if !ok {
s.mu.Unlock()
utils.Log.Warnf("[transcode][chunk] ensureChunk: chunk %d not in session (job=%s)", idx, s.JobID)
return
}
now := time.Now()
c.LastAccess = now
prevState := c.State
switch c.State {
case ChunkRunning:
s.mu.Unlock()
utils.Log.Debugf("[transcode][chunk] ensureChunk: chunk %d already running (job=%s)", idx, s.JobID)
return
case ChunkDone:
s.mu.Unlock()
utils.Log.Debugf("[transcode][chunk] ensureChunk: chunk %d already done (job=%s)", idx, s.JobID)
return
}
// 【关键修复】ChunkFailed 也允许重启:因为可能是网络抖动、source URL 临时失效等
// 而不是永久性故障;如果连续失败,runChunk 内部不会陷入死循环(每次都会重新尝试源)
// ChunkIdle / ChunkKilled / ChunkFailed → 需要启动
running := 0
var lruIdx = -1
var lruTime = now
for i, ck := range s.chunks {
if ck.State == ChunkRunning {
running++
if i != idx && ck.LastAccess.Before(lruTime) {
lruTime = ck.LastAccess
lruIdx = i
}
}
}
if running >= s.MaxConcurrent && lruIdx >= 0 {
victim := s.chunks[lruIdx]
utils.Log.Infof("[transcode][chunk] LRU evict chunk %d (job=%s) to make room for chunk %d",
lruIdx, s.JobID, idx)
if victim.cancel != nil {
victim.cancel()
}
victim.State = ChunkKilled
}
// 启动 chunk ffmpeg
c.State = ChunkRunning
c.StartedAt = now
c.doneCh = make(chan struct{})
c.doneOnce = sync.Once{}
s.mu.Unlock()
utils.Log.Infof("[transcode][chunk] ensureChunk: launching chunk %d (job=%s prevState=%v running=%d)",
idx, s.JobID, prevState, running)
go cm.runChunk(s, idx)
}
// runChunk 实际启动 ffmpeg 转码该 chunk
func (cm *ChunkManager) runChunk(s *ChunkSession, idx int) {
s.mu.Lock()
c := s.chunks[idx]
s.mu.Unlock()
if c == nil {
return
}
timeoutMin := setting.GetInt(conf.TranscodeJobTimeoutMin, 120)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMin)*time.Minute)
s.mu.Lock()
c.cancel = cancel
s.mu.Unlock()
defer cancel()
segPattern := filepath.Join(s.OutDir, "seg-%d.ts")
// 每个 chunk 写自己的 playlist 文件,防止互相覆盖;后端不读这个 playlist 用于响应,
// 真正的总 playlist 由 cache.BuildPlaylist 根据总时长生成。
chunkPlaylist := filepath.Join(s.OutDir, fmt.Sprintf("_chunk_%d.m3u8", idx))
args := buildFFmpegChunkArgs(s.Job, s.ProfDef, s.SegDur, c.StartTime, c.Duration, c.StartSeq, segPattern, chunkPlaylist)
ffmpegPath := setting.GetStr(conf.TranscodeFFmpegPath, "ffmpeg")
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stderr := &limitedBuffer{maxSize: 32 * 1024}
cmd.Stderr = stderr
utils.Log.Infof("[transcode][chunk] start ffmpeg job=%s chunk=%d ss=%.1f t=%.1f startSeq=%d",
s.JobID, idx, c.StartTime, c.Duration, c.StartSeq)
startedAt := time.Now()
err := cmd.Run()
elapsed := time.Since(startedAt)
s.mu.Lock()
switch {
case ctx.Err() != nil && c.State == ChunkKilled:
// 被 LRU 主动 kill,状态保持 Killed
utils.Log.Infof("[transcode][chunk] chunk %d killed (job=%s elapsed=%.1fs)", idx, s.JobID, elapsed.Seconds())
case err != nil:
c.State = ChunkFailed
c.Error = truncate(stderr.String(), 512)
utils.Log.Errorf("[transcode][chunk] chunk %d failed (job=%s elapsed=%.1fs): %v stderr=%s",
idx, s.JobID, elapsed.Seconds(), err, c.Error)
default:
c.State = ChunkDone
utils.Log.Infof("[transcode][chunk] chunk %d done (job=%s elapsed=%.1fs)", idx, s.JobID, elapsed.Seconds())
}
c.chunkDoneClose()
s.mu.Unlock()
// 删除临时 playlist 文件
_ = os.Remove(chunkPlaylist)
// 【关键修复】chunk 自然完成时,必须用 finalScan-style 扫描,否则边界切片(最大 seq)
// 永远不会被登记到 cache,导致下次播放/拖动到该位置时 60 秒超时
if cm.mgr != nil && cm.mgr.localRunner != nil {
cm.mgr.localRunner.publishChunkSegments(s.Job, s.ProfDef, s.OutDir, c.StartSeq, c.EndSeq)
}
}
// sessionLoop 每个 session 的后台维护:空闲清理 + 全部完成检测 + 预读
func (cm *ChunkManager) sessionLoop(s *ChunkSession) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-cm.stopCh:
cm.killAllChunks(s)
return
case <-s.allDoneCh:
return
case <-t.C:
cm.maintainSession(s)
}
}
}
// maintainSession 一次维护:检查空闲、预读下一 chunk、判断是否全部完成
func (cm *ChunkManager) maintainSession(s *ChunkSession) {
now := time.Now()
idleThreshold := time.Duration(s.IdleSec) * time.Second
s.mu.Lock()
// 1. 空闲清理:Running 状态但长时间未访问 → kill
for _, c := range s.chunks {
if c.State == ChunkRunning && now.Sub(c.LastAccess) > idleThreshold {
utils.Log.Infof("[transcode][chunk] chunk %d idle for %.0fs, killing (job=%s)",
c.Index, now.Sub(c.LastAccess).Seconds(), s.JobID)
if c.cancel != nil {
c.cancel()
}
c.State = ChunkKilled
}
}
// 2. 预读:找出最近被访问的 Running chunk,如果它的进度过半,启动下一个 chunk
var prefetchTarget = -1
if s.Prefetch {
var hottestIdx = -1
var hottestAccess time.Time
for i, c := range s.chunks {
if c.State == ChunkRunning && c.LastAccess.After(hottestAccess) {
hottestAccess = c.LastAccess
hottestIdx = i
}
}
if hottestIdx >= 0 {
hot := s.chunks[hottestIdx]
// 估算进度:用 ffmpeg 已经写出的切片数 / 该 chunk 总切片数
// 由于 cache.segments 是全局的,我们检查范围 [StartSeq, EndSeq) 中已就绪数量
done := cm.countReadySegments(s, hot.StartSeq, hot.EndSeq)
expected := hot.EndSeq - hot.StartSeq
if expected > 0 && done*2 >= expected { // 进度 >= 50%
next := hottestIdx + 1
if nc, ok := s.chunks[next]; ok && nc.State == ChunkIdle {
prefetchTarget = next
}
}
}
}
// 3. 全部完成检测:所有 chunk 都 Done
allDone := true
for _, c := range s.chunks {
if c.State != ChunkDone {
allDone = false
break
}
}
s.mu.Unlock()
if prefetchTarget >= 0 {
utils.Log.Infof("[transcode][chunk] prefetch chunk %d (job=%s)", prefetchTarget, s.JobID)
cm.ensureChunk(s, prefetchTarget)
}
if allDone {
s.allDoneClose()
}
}
// countReadySegments 统计 [start, end) 范围内已就绪的切片数
func (cm *ChunkManager) countReadySegments(s *ChunkSession, start, end int) int {
if cm.mgr == nil || cm.mgr.Cache == nil {
return 0
}
cnt := 0
for seq := start; seq < end; seq++ {
if _, err := cm.mgr.Cache.GetSegment(s.JobID, s.Profile, seq); err == nil {
cnt++
}
}
return cnt
}
// killAllChunks 关闭某 session 下所有运行中的 ffmpeg
func (cm *ChunkManager) killAllChunks(s *ChunkSession) {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.chunks {
if c.State == ChunkRunning && c.cancel != nil {
c.cancel()
c.State = ChunkKilled
}
}
}
// CancelSession 外部取消(job 被 cancel 或 idle 超时)
func (cm *ChunkManager) CancelSession(jobID, profile string) {
cm.mu.Lock()
s, ok := cm.sessions[sessionKey(jobID, profile)]
if ok {
delete(cm.sessions, sessionKey(jobID, profile))
}
cm.mu.Unlock()
if !ok {
return
}
cm.killAllChunks(s)
s.allDoneClose()
}
// CancelAllForJob 取消该 job 下所有 profile 的 session(CancelJob 时调用)
func (cm *ChunkManager) CancelAllForJob(jobID string) {
cm.mu.Lock()
var targets []*ChunkSession
for k, s := range cm.sessions {
if s.JobID == jobID {
targets = append(targets, s)
delete(cm.sessions, k)
}
}
cm.mu.Unlock()
for _, s := range targets {
cm.killAllChunks(s)
s.allDoneClose()
}
}
// WaitDone 阻塞等待所有 chunk 完成(LocalRunner.runJob 用)
func (s *ChunkSession) WaitDone() <-chan struct{} { return s.allDoneCh }
// Stop 停止 ChunkManager(Manager.Stop 调用)
func (cm *ChunkManager) Stop() {
close(cm.stopCh)
cm.mu.Lock()
defer cm.mu.Unlock()
for _, s := range cm.sessions {
cm.killAllChunks(s)
s.allDoneClose()
}
cm.sessions = nil
}
-131
View File
@@ -1,131 +0,0 @@
package transcode
import (
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
)
// DecideRequest 决策器输入
type DecideRequest struct {
FileName string
FileSize int64 // 字节
Probe SourceProbe // 可选;为空则只用大小+后缀判断
}
// DecideResult 决策结果
type DecideResult struct {
NeedTranscode bool
Reason string
}
// Decide 根据全局设置判断是否需要走转码
func Decide(r DecideRequest) DecideResult {
if !setting.GetBool(conf.TranscodeEnabled) {
return DecideResult{false, "transcode disabled"}
}
// 1. 大小阈值
minGB := setting.GetInt(conf.TranscodeMinSizeGB, 5)
if minGB > 0 {
minBytes := int64(minGB) * 1024 * 1024 * 1024
if r.FileSize > 0 && r.FileSize < minBytes {
return DecideResult{false, "size below threshold"}
}
}
// 2. 后缀过滤
exts := splitCSV(setting.GetStr(conf.TranscodeSourceExtensions))
if len(exts) > 0 {
ext := strings.ToLower(strings.TrimPrefix(getExt(r.FileName), "."))
if !containsFold(exts, ext) {
return DecideResult{false, "extension not in list"}
}
}
// 3. 编码过滤(仅当 probe 提供时)
codecs := splitCSV(setting.GetStr(conf.TranscodeSourceCodecs))
if r.Probe.VideoCodec != "" && len(codecs) > 0 {
if !containsFold(codecs, r.Probe.VideoCodec) {
return DecideResult{false, "codec not in list"}
}
}
// 4. 码率过滤(仅当 probe 提供时)
minBR := setting.GetInt(conf.TranscodeMinBitrateMbps, 0)
if minBR > 0 && r.Probe.VideoBitrate > 0 {
if r.Probe.VideoBitrate < int64(minBR)*1_000_000 {
return DecideResult{false, "bitrate below threshold"}
}
}
return DecideResult{true, "ok"}
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func containsFold(arr []string, v string) bool {
for _, a := range arr {
if strings.EqualFold(a, v) {
return true
}
}
return false
}
func getExt(name string) string {
i := strings.LastIndex(name, ".")
if i < 0 {
return ""
}
return name[i:]
}
// BuildProfile 根据当前全局设置构建默认 Profile
func BuildProfile() Profile {
res := setting.GetStr(conf.TranscodeOutputResolution)
scale := ""
name := "default"
switch res {
case "source":
scale = ""
name = "source"
case "3840x2160":
scale = "3840:-2"
name = "2160p"
case "2560x1440":
scale = "2560:-2"
name = "1440p"
case "1920x1080":
scale = "1920:-2"
name = "1080p"
case "1280x720":
scale = "1280:-2"
name = "720p"
case "854x480":
scale = "854:-2"
name = "480p"
}
return Profile{
Name: name,
VideoCodec: setting.GetStr(conf.TranscodeOutputCodec),
VideoBitrate: setting.GetStr(conf.TranscodeOutputBitrate),
Scale: scale,
AudioCodec: setting.GetStr(conf.TranscodeOutputAudioCodec),
AudioBitrate: setting.GetStr(conf.TranscodeOutputAudioBitrate),
HWAccel: setting.GetStr(conf.TranscodeHWAccel),
}
}
// BuildOutputSpec 根据当前全局设置构建输出规格
func BuildOutputSpec() OutputSpec {
return OutputSpec{
Format: OutputFormat(setting.GetStr(conf.TranscodeOutputFormat)),
SegmentDuration: setting.GetInt(conf.TranscodeSegmentDuration, 6),
}
}
-767
View File
@@ -1,767 +0,0 @@
package transcode
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// limitedBuffer 是一个有容量上限的 io.Writer,超出 maxSize 后静默丢弃。
// 用于捕获 ffmpeg stderr,防止异常文件导致无限内存增长。
type limitedBuffer struct {
data []byte
maxSize int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remain := b.maxSize - len(b.data)
if remain <= 0 {
return len(p), nil // 静默丢弃
}
if len(p) > remain {
p = p[:remain]
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *limitedBuffer) String() string {
return string(b.data)
}
// LocalRunner 本地内置 worker,直接调用本机 ffmpeg
type LocalRunner struct {
mgr *Manager
workerID string
stopCh chan struct{}
wg sync.WaitGroup
// 运行中 job 的 cancel 管理:jobID -> cancelFunc
runMu sync.Mutex
runningJobs map[string]context.CancelFunc
}
func NewLocalRunner(mgr *Manager) *LocalRunner {
return &LocalRunner{
mgr: mgr,
stopCh: make(chan struct{}),
runningJobs: make(map[string]context.CancelFunc),
}
}
// Start 注册本地 worker 并启动消费协程
func (r *LocalRunner) Start() error {
concurrency := setting.GetInt(conf.TranscodeLocalConcurrency, 1)
if concurrency <= 0 {
concurrency = 1
}
hwaccel := setting.GetStr(conf.TranscodeHWAccel, "none")
hostname, _ := os.Hostname()
w := r.mgr.Registry.Register(&RegisterRequest{
Name: "local-" + hostname,
Version: "embedded",
Capacity: concurrency,
HWAccel: []string{hwaccel},
CodecsDecode: []string{"h264", "hevc", "av1", "vp9", "mpeg4", "mpeg2video"},
CodecsEncode: []string{"h264", "hevc"},
MaxResolution: "3840x2160",
Tags: []string{"local", "embedded"},
}, true)
r.workerID = w.ID
utils.Log.Infof("[transcode] local worker started: id=%s capacity=%d hwaccel=%s", w.ID, concurrency, hwaccel)
for i := 0; i < concurrency; i++ {
r.wg.Add(1)
go r.loop()
}
return nil
}
func (r *LocalRunner) Stop() {
close(r.stopCh)
// 取消所有运行中的 FFmpeg 进程
r.runMu.Lock()
for id, cancel := range r.runningJobs {
utils.Log.Infof("[transcode] stopping ffmpeg for job %s (runner shutdown)", id)
cancel()
}
r.runMu.Unlock()
r.wg.Wait()
r.mgr.Registry.Unregister(r.workerID)
}
// CancelJob 外部取消指定 job 的 FFmpeg 进程(由 Scheduler.Cancel / 空闲超时调用)
func (r *LocalRunner) CancelJob(jobID string) bool {
r.runMu.Lock()
cancel, ok := r.runningJobs[jobID]
r.runMu.Unlock()
if ok {
utils.Log.Infof("[transcode] killing ffmpeg for job %s (external cancel)", jobID)
cancel()
}
return ok
}
// RunningJobIDs 返回当前正在运行的所有 job ID 列表
func (r *LocalRunner) RunningJobIDs() []string {
r.runMu.Lock()
defer r.runMu.Unlock()
ids := make([]string, 0, len(r.runningJobs))
for id := range r.runningJobs {
ids = append(ids, id)
}
return ids
}
func (r *LocalRunner) loop() {
defer r.wg.Done()
for {
select {
case <-r.stopCh:
return
default:
}
jobs := r.mgr.Scheduler.Claim(r.workerID, 1, 5*time.Second)
if len(jobs) == 0 {
continue
}
for _, j := range jobs {
r.runJob(j)
}
}
}
// runJob 执行一个转码任务(单 profile,HLS 输出,边转边写入 cache)
func (r *LocalRunner) runJob(job *Job) {
utils.Log.Infof("[transcode] local runner picked job %s (%s)", job.ID, job.Path)
if len(job.Profiles) == 0 {
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: "no profiles"})
return
}
profile := job.Profiles[0]
timeoutMin := setting.GetInt(conf.TranscodeJobTimeoutMin, 120)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMin)*time.Minute)
defer cancel()
// 注册到 runningJobs,支持外部取消
r.runMu.Lock()
r.runningJobs[job.ID] = cancel
r.runMu.Unlock()
defer func() {
r.runMu.Lock()
delete(r.runningJobs, job.ID)
r.runMu.Unlock()
}()
// 输出目录
jc, err := r.mgr.Cache.getOrCreate(job.ID, profile.Name)
if err != nil {
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: err.Error()})
return
}
outDir := jc.BaseDir
// 【智能调度】如果探测到了视频总时长,启用 chunk 模式(按需分段并发转码)
// 否则回退到旧的"一次性整段"模式(兼容 ffprobe 失败的场景)
if job.Probe.Duration > 0 && r.mgr.Chunks != nil {
r.runJobByChunks(ctx, job, profile, outDir)
return
}
// === 旧路径:一次性整段转码 ===
r.runJobLegacy(ctx, job, profile, outDir)
}
// runJobByChunks 智能 chunk 调度模式:按需启动 ffmpeg 进程,支持随机访问
func (r *LocalRunner) runJobByChunks(ctx context.Context, job *Job, profile Profile, outDir string) {
startedAt := time.Now()
// 启动 watchSegments:周期扫描目录把切片登记到 cache
stopWatcher := make(chan struct{})
var watcherWG sync.WaitGroup
watcherWG.Add(1)
go func() {
defer watcherWG.Done()
r.watchSegments(job, profile, outDir, stopWatcher)
}()
// 创建 chunk session(会立刻启动 chunk-0 + 后台维护协程)
session, err := r.mgr.Chunks.StartSession(job, profile, outDir)
if err != nil {
close(stopWatcher)
watcherWG.Wait()
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: err.Error()})
return
}
// 阻塞等待全部 chunk 完成 / 任务被取消
select {
case <-session.WaitDone():
// 所有 chunk 都 Done,正常完成
case <-ctx.Done():
// 外部取消:通知 chunk session 杀掉所有 ffmpeg
r.mgr.Chunks.CancelSession(job.ID, profile.Name)
}
close(stopWatcher)
watcherWG.Wait()
r.scanAndPublish(job, profile, outDir, true)
if ctx.Err() != nil {
utils.Log.Infof("[transcode] job %s chunk session cancelled", job.ID)
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobCancelled,
Error: "cancelled: " + ctx.Err().Error(),
})
return
}
utils.Log.Infof("[transcode] job %s completed in %.1fs (chunk mode)", job.ID, time.Since(startedAt).Seconds())
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobFinished,
Stats: JobStats{Elapsed: time.Since(startedAt).Seconds()},
})
}
// runJobLegacy 旧路径:一次性整段转码(不知道总时长时回退使用)
func (r *LocalRunner) runJobLegacy(ctx context.Context, job *Job, profile Profile, outDir string) {
playlistPath := filepath.Join(outDir, "playlist.m3u8")
segPattern := filepath.Join(outDir, "seg-%d.ts")
args := buildFFmpegArgs(job, profile, segPattern, playlistPath)
ffmpegPath := setting.GetStr(conf.TranscodeFFmpegPath, "ffmpeg")
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stderr := &limitedBuffer{maxSize: 64 * 1024}
cmd.Stderr = stderr
startedAt := time.Now()
if err := cmd.Start(); err != nil {
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: err.Error()})
return
}
stopWatcher := make(chan struct{})
var watcherWG sync.WaitGroup
watcherWG.Add(1)
go func() {
defer watcherWG.Done()
r.watchSegments(job, profile, outDir, stopWatcher)
}()
err := cmd.Wait()
close(stopWatcher)
watcherWG.Wait()
r.scanAndPublish(job, profile, outDir, true)
if err != nil {
if ctx.Err() != nil {
utils.Log.Infof("[transcode] job %s ffmpeg killed (context cancelled)", job.ID)
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobCancelled,
Error: "cancelled: " + ctx.Err().Error(),
})
} else {
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobFailed,
Error: fmt.Sprintf("ffmpeg failed: %v, stderr=%s", err, truncate(stderr.String(), 1024)),
})
}
return
}
utils.Log.Infof("[transcode] job %s completed in %.1fs (legacy mode)", job.ID, time.Since(startedAt).Seconds())
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobFinished,
Stats: JobStats{Elapsed: time.Since(startedAt).Seconds()},
})
}
// watchSegments 周期性扫描输出目录,把新切片登记到 Cache
// 【内存优化】把扫描频率从 500ms 降到 2s,减少每次 os.ReadDir 和 parseFFmpegPlaylistDurations
// 触发的字符串/字节切片分配。chunk 模式下每个 chunk 完成时还会主动调用 publishChunkSegments,
// 所以这里只需要兜底登记中间切片,2s 频率完全够用。
func (r *LocalRunner) watchSegments(job *Job, profile Profile, dir string, stop chan struct{}) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
r.scanAndPublish(job, profile, dir, false)
}
}
}
func (r *LocalRunner) scanAndPublish(job *Job, profile Profile, dir string, finalScan bool) {
jc, err := r.mgr.Cache.getOrCreate(job.ID, profile.Name)
if err != nil {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type segFile struct {
seq int
path string
size int64
}
var segs []segFile
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "seg-") || !strings.HasSuffix(name, ".ts") {
continue
}
numStr := strings.TrimSuffix(strings.TrimPrefix(name, "seg-"), ".ts")
seq, err := strconv.Atoi(numStr)
if err != nil {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
segs = append(segs, segFile{seq: seq, path: filepath.Join(dir, name), size: fi.Size()})
}
sort.Slice(segs, func(i, j int) bool { return segs[i].seq < segs[j].seq })
// 【优化】解析 ffmpeg 写出的 playlist.m3u8 获取每个切片的真实时长
// 避免硬编码 segDur 导致 HLS.js 显示总时长不准确
realDurations := parseFFmpegPlaylistDurations(filepath.Join(dir, "playlist.m3u8"))
// ffmpeg 在写入 .ts 时是边写边写,稳妥起见仅登记除最后一片以外的切片(最后一片可能未写完)
limit := len(segs)
if !finalScan && limit > 0 {
limit--
}
defaultDur := float64(setting.GetInt(conf.TranscodeSegmentDuration, 6))
for i := 0; i < limit; i++ {
s := segs[i]
jc.mu.RLock()
_, exists := jc.segments[s.seq]
jc.mu.RUnlock()
if exists {
continue
}
// 优先用 ffmpeg playlist 中的真实时长
dur := defaultDur
if d, ok := realDurations[s.seq]; ok && d > 0 {
dur = d
}
// 直接登记,不复制(已经在 cache 目录里了)
jc.mu.Lock()
jc.segments[s.seq] = &SegmentInfo{Seq: s.seq, Duration: dur, Size: s.size, Path: s.path}
var toClose []chan struct{}
for sk, chs := range jc.waiters {
if sk <= s.seq {
toClose = append(toClose, chs...)
delete(jc.waiters, sk)
}
}
jc.mu.Unlock()
for _, ch := range toClose {
close(ch)
}
// 第一片就绪 → 通知 job ready
if s.seq == 0 {
job.MarkReady()
}
}
if finalScan {
jc.mu.Lock()
jc.final = true
jc.mu.Unlock()
}
}
// publishChunkSegments 把 [startSeq, endSeq) 范围内已经写完的切片登记到 cache。
// 与 scanAndPublish 不同,这个方法专门用于"某个 chunk 的 ffmpeg 已经退出"的场景,
// 此时该范围的所有 .ts 文件都是完整的,不能再丢掉最大序号的那片(避免边界切片永远丢失)。
func (r *LocalRunner) publishChunkSegments(job *Job, profile Profile, dir string, startSeq, endSeq int) {
jc, err := r.mgr.Cache.getOrCreate(job.ID, profile.Name)
if err != nil {
return
}
defaultDur := float64(setting.GetInt(conf.TranscodeSegmentDuration, 6))
registered := 0
for seq := startSeq; seq < endSeq; seq++ {
jc.mu.RLock()
_, exists := jc.segments[seq]
jc.mu.RUnlock()
if exists {
continue
}
fpath := filepath.Join(dir, fmt.Sprintf("seg-%d.ts", seq))
fi, err := os.Stat(fpath)
if err != nil || fi.IsDir() || fi.Size() == 0 {
continue
}
jc.mu.Lock()
jc.segments[seq] = &SegmentInfo{Seq: seq, Duration: defaultDur, Size: fi.Size(), Path: fpath}
var toClose []chan struct{}
for sk, chs := range jc.waiters {
if sk <= seq {
toClose = append(toClose, chs...)
delete(jc.waiters, sk)
}
}
jc.mu.Unlock()
for _, ch := range toClose {
close(ch)
}
if seq == 0 {
job.MarkReady()
}
registered++
}
if registered > 0 {
utils.Log.Infof("[transcode][chunk] published %d segments [%d, %d) for job=%s",
registered, startSeq, endSeq, job.ID)
}
}
// playlistDurCache 缓存 m3u8 解析结果,按文件 mtime+size 失效。
// 【内存优化】scanAndPublish 每 2 秒一次,每次都会解析同一个 m3u8(长视频几百 KB),
// strings.Split 会产生大量临时字符串切片造成 GC 压力。用缓存减少 99% 的重复解析。
type playlistDurCacheEntry struct {
mtime time.Time
size int64
durs map[int]float64
}
var (
playlistDurCache = make(map[string]*playlistDurCacheEntry)
playlistDurCacheMu sync.Mutex
)
// parseFFmpegPlaylistDurations 解析 ffmpeg 写出的 m3u8,返回 seq -> duration 的映射
// 用于获取每个切片的真实时长,避免后端用硬编码 segDur 导致总时长显示偏差
func parseFFmpegPlaylistDurations(path string) map[int]float64 {
fi, err := os.Stat(path)
if err != nil {
return map[int]float64{}
}
// 命中缓存:mtime + size 都没变就直接复用
playlistDurCacheMu.Lock()
if e, ok := playlistDurCache[path]; ok && e.mtime.Equal(fi.ModTime()) && e.size == fi.Size() {
playlistDurCacheMu.Unlock()
return e.durs
}
playlistDurCacheMu.Unlock()
out := make(map[int]float64)
data, err := os.ReadFile(path)
if err != nil {
return out
}
lines := strings.Split(string(data), "\n")
var pendingDur float64
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#EXTINF:") {
// #EXTINF:6.000000,
body := strings.TrimPrefix(line, "#EXTINF:")
body = strings.TrimSuffix(body, ",")
d, err := strconv.ParseFloat(strings.TrimSpace(body), 64)
if err == nil {
pendingDur = d
}
continue
}
if strings.HasSuffix(line, ".ts") && strings.HasPrefix(line, "seg-") {
numStr := strings.TrimSuffix(strings.TrimPrefix(line, "seg-"), ".ts")
if seq, err := strconv.Atoi(numStr); err == nil && pendingDur > 0 {
out[seq] = pendingDur
}
pendingDur = 0
}
}
// 写入缓存
playlistDurCacheMu.Lock()
playlistDurCache[path] = &playlistDurCacheEntry{
mtime: fi.ModTime(),
size: fi.Size(),
durs: out,
}
playlistDurCacheMu.Unlock()
return out
}
// buildFFmpegArgs 构造 ffmpeg 命令行(一次性整段转码,兼容旧路径)
// 当 Probe.Duration 不可用时,LocalRunner.runJob 会回退到这条路径。
func buildFFmpegArgs(job *Job, profile Profile, segPattern, playlistPath string) []string {
segDur := setting.GetInt(conf.TranscodeSegmentDuration, 6)
// startTime=0, duration=0(=不限) 表示整段转码;startSeq=0
return buildFFmpegArgsCommon(job, profile, segDur, 0, 0, 0, segPattern, playlistPath)
}
// buildFFmpegChunkArgs 构造 chunk 模式的 ffmpeg 命令行
// startTime: 起始时间(秒);duration: 持续时长(秒,0=不限);startSeq: 切片起始编号
// chunk 模式与一次性整段共用底层参数构造,差异仅在 -ss/-t/-start_number。
func buildFFmpegChunkArgs(job *Job, profile Profile, segDur int, startTime, duration float64, startSeq int, segPattern, playlistPath string) []string {
return buildFFmpegArgsCommon(job, profile, segDur, startTime, duration, startSeq, segPattern, playlistPath)
}
// buildFFmpegArgsCommon 通用 ffmpeg 命令行构造
// 当 startTime > 0 或 duration > 0 时,启用"chunk 模式":使用 -ss / -t / -start_number 限定范围
// 关键:-ss 必须放在 -i 之前(input seek,快),且需配合 force_key_frames 让 chunk 起始处生成 IDR 帧
func buildFFmpegArgsCommon(job *Job, profile Profile, segDur int, startTime, duration float64, startSeq int, segPattern, playlistPath string) []string {
hw := strings.ToLower(profile.HWAccel)
args := []string{"-y", "-loglevel", "warning"}
// 输入侧硬件加速
switch hw {
case "nvenc":
args = append(args, "-hwaccel", "cuda")
case "qsv":
args = append(args, "-hwaccel", "qsv")
case "vaapi":
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", "/dev/dri/renderD128")
case "amf":
args = append(args, "-hwaccel", "d3d11va")
case "videotoolbox":
args = append(args, "-hwaccel", "videotoolbox")
case "auto":
args = append(args, "-hwaccel", "auto")
}
// 【chunk 模式】input seek:放在 -i 之前,速度快,会按关键帧对齐
// 注意 -ss 在 -i 之前是 input seek(用容器索引快速跳转),但落点是关键帧;
// 我们通过 force_key_frames 保证每个 chunk 边界都有关键帧,所以这里精度足够。
if startTime > 0.001 {
args = append(args, "-ss", fmt.Sprintf("%.3f", startTime))
}
// 输入
args = append(args, "-i", job.SourceURL)
// 【chunk 模式】限定时长(output side 更精确)
if duration > 0.001 {
args = append(args, "-t", fmt.Sprintf("%.3f", duration))
}
// 视频编码器
vcodec := pickEncoder(profile.VideoCodec, hw)
args = append(args, "-c:v", vcodec)
// 【关键】强制浏览器兼容的 profile/level/pix_fmt,必须与 master.m3u8 声明的
// CODECS="avc1.640028" (H.264 High Profile Level 4.0) 一致,否则 HLS.js 会触发
// bufferAppendError 死循环。
if strings.Contains(vcodec, "264") {
args = append(args, "-profile:v", "high", "-level:v", "4.0", "-pix_fmt", "yuv420p")
}
if profile.VideoBitrate != "" {
args = append(args, "-b:v", profile.VideoBitrate)
args = append(args, "-maxrate", profile.VideoBitrate, "-bufsize", profile.VideoBitrate)
}
if profile.Scale != "" {
// 软件 / 硬件缩放
switch hw {
case "vaapi":
args = append(args, "-vf", "scale_vaapi="+profile.Scale)
case "qsv":
args = append(args, "-vf", "scale_qsv="+profile.Scale)
default:
args = append(args, "-vf", "scale="+profile.Scale)
}
}
args = append(args, "-preset", defaultPresetFor(vcodec))
// 音频
// 【关键修复】不能使用 -c:a copy,因为浏览器 MSE 不支持 AC3/DTS/EAC3/TrueHD 等
// 常见于 mkv 的音频编码。直接 copy 会导致 SourceBuffer.appendBuffer 失败
// 触发 bufferAppendError 死循环。统一强制转 AAC 兼容所有浏览器。
ac := profile.AudioCodec
if ac == "" || ac == "copy" {
ac = "aac"
}
args = append(args, "-c:a", ac)
ab := profile.AudioBitrate
if ab == "" {
ab = "160k"
}
args = append(args, "-b:a", ab)
// 强制立体声 + 48kHz,避免 5.1 声道 / 高采样率导致的兼容问题
args = append(args, "-ac", "2", "-ar", "48000")
// HLS 输出
if segDur <= 0 {
segDur = setting.GetInt(conf.TranscodeSegmentDuration, 6)
}
// 【关键】强制关键帧对齐切片边界,确保每个切片以 IDR 帧开始可独立解码。
// 没有这些参数时,第一个切片可能不含 IDR 帧,导致 HLS.js bufferAppendError。
// fps 假设 25 (帧率未知时的保守值),gop = segDur * fps;force_key_frames 让
// ffmpeg 在每个切片边界强制插入关键帧。
gop := segDur * 25
args = append(args,
"-g", strconv.Itoa(gop),
"-keyint_min", strconv.Itoa(gop),
"-sc_threshold", "0",
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segDur),
)
// 【chunk 模式 - 关键修复】input seek 后 ffmpeg 会把第一帧 PTS 重置为 0,
// 这会导致 chunk-30 的切片在播放器看来是 PTS=0~60s(而不是 1800~1860s),
// 进而触发 HLS.js bufferAppendError 或时间轴错位。
// 解决方法:用 -output_ts_offset 让生成的切片时间戳偏移到原始位置。
if startTime > 0.001 {
args = append(args, "-output_ts_offset", fmt.Sprintf("%.3f", startTime))
}
args = append(args,
"-f", "hls",
"-hls_time", strconv.Itoa(segDur),
"-hls_playlist_type", "vod",
"-hls_segment_filename", segPattern,
"-hls_flags", "independent_segments+temp_file",
)
// 【chunk 模式】指定起始切片编号,让全局切片编号连续
if startSeq > 0 {
args = append(args, "-start_number", strconv.Itoa(startSeq))
}
args = append(args, playlistPath)
return args
}
// pickEncoder 根据视频编码 + HW 类型选择 ffmpeg 编码器
func pickEncoder(codec, hw string) string {
codec = strings.ToLower(codec)
switch codec {
case "h264", "":
switch hw {
case "nvenc":
return "h264_nvenc"
case "qsv":
return "h264_qsv"
case "vaapi":
return "h264_vaapi"
case "amf":
return "h264_amf"
case "videotoolbox":
return "h264_videotoolbox"
default:
return "libx264"
}
case "hevc", "h265":
switch hw {
case "nvenc":
return "hevc_nvenc"
case "qsv":
return "hevc_qsv"
case "vaapi":
return "hevc_vaapi"
case "amf":
return "hevc_amf"
case "videotoolbox":
return "hevc_videotoolbox"
default:
return "libx265"
}
case "av1":
switch hw {
case "nvenc":
return "av1_nvenc"
case "qsv":
return "av1_qsv"
default:
return "libsvtav1"
}
}
return "libx264"
}
func defaultPresetFor(encoder string) string {
if strings.Contains(encoder, "nvenc") {
return "p5"
}
if strings.Contains(encoder, "qsv") {
return "medium"
}
if strings.Contains(encoder, "amf") {
return "balanced"
}
if strings.Contains(encoder, "videotoolbox") {
return "medium"
}
return "veryfast"
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}
// ProbeSource 调用 ffprobe 探测源文件
func ProbeSource(url string) (SourceProbe, error) {
probePath := setting.GetStr(conf.TranscodeFFprobePath, "ffprobe")
args := []string{
"-v", "error",
"-show_entries", "format=duration,size,bit_rate:stream=codec_type,codec_name,bit_rate,width,height",
"-of", "json",
url,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, probePath, args...)
if runtime.GOOS == "windows" {
// no special handling
}
out, err := cmd.Output()
if err != nil {
return SourceProbe{}, err
}
var raw struct {
Format struct {
Duration string `json:"duration"`
Size string `json:"size"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
BitRate string `json:"bit_rate"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &raw); err != nil {
return SourceProbe{}, err
}
p := SourceProbe{}
p.Duration, _ = strconv.ParseFloat(raw.Format.Duration, 64)
p.Size, _ = strconv.ParseInt(raw.Format.Size, 10, 64)
for _, s := range raw.Streams {
if s.CodecType == "video" && p.VideoCodec == "" {
p.VideoCodec = s.CodecName
p.Width = s.Width
p.Height = s.Height
p.VideoBitrate, _ = strconv.ParseInt(s.BitRate, 10, 64)
}
if s.CodecType == "audio" && p.AudioCodec == "" {
p.AudioCodec = s.CodecName
p.AudioBitrate, _ = strconv.ParseInt(s.BitRate, 10, 64)
}
}
return p, nil
}
-173
View File
@@ -1,173 +0,0 @@
package transcode
import (
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// Manager 全局转码管理器单例
type Manager struct {
Registry *WorkerRegistry
Scheduler *Scheduler
Cache *Cache
Chunks *ChunkManager // 智能 chunk 调度器(按需启动 ffmpeg,支持随机访问)
localRunner *LocalRunner
stopCh chan struct{}
once sync.Once
}
var (
defaultManager *Manager
managerOnce sync.Once
)
// Default 返回全局 Manager(懒加载)
func Default() *Manager {
managerOnce.Do(func() {
defaultManager = newManager()
})
return defaultManager
}
func newManager() *Manager {
reg := NewWorkerRegistry()
sch := NewScheduler(reg)
cachePath := setting.GetStr(conf.TranscodeCachePath, "data/transcode_cache")
cacheMaxGB := int64(setting.GetInt(conf.TranscodeCacheMaxGB, 20))
cache := NewCache(cachePath, cacheMaxGB)
_ = cache.Init()
m := &Manager{
Registry: reg,
Scheduler: sch,
Cache: cache,
stopCh: make(chan struct{}),
}
m.Chunks = NewChunkManager(m)
return m
}
// Start 启动后台维护协程:心跳清理 + 缓存 LRU + 完成任务清理
func (m *Manager) Start() {
m.once.Do(func() {
go m.maintenance()
// 根据 run_mode 决定是否启动本地 worker
mode := setting.GetStr(conf.TranscodeRunMode, "local")
if mode == "local" || mode == "hybrid" {
m.startLocalWorker()
}
})
}
// Stop 停止 Manager
func (m *Manager) Stop() {
close(m.stopCh)
if m.Chunks != nil {
m.Chunks.Stop()
}
if m.localRunner != nil {
m.localRunner.Stop()
}
}
func (m *Manager) maintenance() {
t1 := time.NewTicker(15 * time.Second) // 心跳清理
t2 := time.NewTicker(5 * time.Minute) // 缓存 LRU
t3 := time.NewTicker(30 * time.Minute) // 历史 job 清理
t4 := time.NewTicker(10 * time.Second) // 空闲超时检测
defer t1.Stop()
defer t2.Stop()
defer t3.Stop()
defer t4.Stop()
for {
select {
case <-m.stopCh:
return
case <-t1.C:
removed := m.Registry.Sweep()
for _, id := range removed {
utils.Log.Infof("[transcode] worker %s removed (heartbeat timeout)", id)
}
case <-t2.C:
_ = m.Cache.EnforceLRU()
case <-t3.C:
purged := m.Scheduler.PurgeBefore(time.Now().Add(-2 * time.Hour))
if len(purged) > 0 {
// 【内存泄漏修复】联动清理 Cache 中对应的 JobCache + segments map
for _, jobID := range purged {
m.Cache.Cleanup(jobID)
}
utils.Log.Infof("[transcode] purged %d finished jobs (cache cleaned)", len(purged))
}
case <-t4.C:
m.sweepIdleJobs()
}
}
}
// sweepIdleJobs 扫描所有活跃任务,超过空闲超时时间无播放端请求则自动取消并 kill FFmpeg
// 注意:这里只 cancel job + kill ffmpeg 释放 CPU,**不删除已转好的切片缓存**。
// 当用户继续播放时,TCSegment / TCPlaylist 会通过 Scheduler.Reactivate 复用同一 jobID/token
// 重新启动转码;已存在的切片可以直接命中,不需要再次转码。
func (m *Manager) sweepIdleJobs() {
idleTimeoutSec := setting.GetInt(conf.TranscodeIdleTimeoutSec, 90)
if idleTimeoutSec <= 0 {
return // 配置为 0 表示禁用空闲超时
}
timeout := time.Duration(idleTimeoutSec) * time.Second
now := time.Now()
activeJobs := m.Scheduler.ListActiveJobs()
for _, j := range activeJobs {
lastAccess := j.GetLastAccess()
idle := now.Sub(lastAccess)
if idle > timeout {
utils.Log.Infof("[transcode] job %s idle for %.0fs (threshold %ds), pausing ffmpeg (cache kept for resume)",
j.ID, idle.Seconds(), idleTimeoutSec)
// 先取消所有 chunk ffmpeg,再调度器层面取消
if m.Chunks != nil {
m.Chunks.CancelAllForJob(j.ID)
}
m.Scheduler.Cancel(j.ID)
// 【重要】不再调用 m.Cache.Cleanup —— 已转好的切片必须保留,
// 这样浏览器恢复播放时旧的 seg-N.ts 仍然能命中,避免 404 和重新转码。
// 真正的磁盘清理由更长周期的 PurgeBefore + Cache.EnforceLRU 负责。
}
}
}
func (m *Manager) startLocalWorker() {
r := NewLocalRunner(m)
if err := r.Start(); err != nil {
utils.Log.Errorf("[transcode] start local worker failed: %+v", err)
return
}
m.localRunner = r
// 注入取消回调:Scheduler.Cancel 时直接 kill 本地 FFmpeg 进程
m.Scheduler.SetOnCancel(func(jobID string) {
// 先清理 chunk session(kill 所有 chunk ffmpeg)
if m.Chunks != nil {
m.Chunks.CancelAllForJob(jobID)
}
if m.localRunner != nil {
m.localRunner.CancelJob(jobID)
}
})
}
// IsEnabled 总开关
func IsEnabled() bool { return setting.GetBool(conf.TranscodeEnabled) }
// VerifyWorkerSecret 校验远程 worker 注册凭据
func VerifyWorkerSecret(token string) bool {
expected := setting.GetStr(conf.TranscodeWorkerSecret)
if expected == "" {
return false // 未配置则禁用远程注册
}
return token == expected
}
-75
View File
@@ -1,75 +0,0 @@
package transcode
// 协议版本
const ProtocolVersion = "v1"
// ---- 注册 ----
type RegisterRequest struct {
Name string `json:"name"`
Version string `json:"version"`
Capacity int `json:"capacity"`
HWAccel []string `json:"hwaccel"`
CodecsDecode []string `json:"codecs_decode"`
CodecsEncode []string `json:"codecs_encode"`
MaxResolution string `json:"max_resolution"`
Tags []string `json:"tags"`
Endpoint string `json:"endpoint,omitempty"`
}
type RegisterResponse struct {
WorkerID string `json:"worker_id"`
HeartbeatInterval int `json:"heartbeat_interval"`
ClaimStrategy string `json:"claim_strategy"` // pull
ProtocolVersion string `json:"protocol_version"`
}
// ---- 心跳 ----
type HeartbeatRequest struct {
WorkerID string `json:"worker_id"`
Load float64 `json:"load"`
Running []string `json:"running"`
FreeSlots int `json:"free_slots"`
}
type HeartbeatResponse struct {
OK bool `json:"ok"`
Kick bool `json:"kick"` // 服务端要求 worker 退出
Cancel []string `json:"cancel,omitempty"` // 需要取消的 job ids
}
// ---- 领任务 ----
type ClaimRequest struct {
WorkerID string `json:"worker_id"`
Slots int `json:"slots"`
Wait int `json:"wait"` // 长轮询超时秒,0=不等
}
type ClaimResponse struct {
Jobs []*Job `json:"jobs"`
}
// ---- 完成 ----
type FinishRequest struct {
JobID string `json:"job_id"`
Status JobStatus `json:"status"`
Error string `json:"error,omitempty"`
Stats JobStats `json:"stats"`
}
type JobStats struct {
Elapsed float64 `json:"elapsed"`
FPS float64 `json:"fps"`
Speed float64 `json:"speed"`
BytesOut int64 `json:"bytes_out"`
}
// ---- 通用响应 ----
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
-122
View File
@@ -1,122 +0,0 @@
package transcode
import (
"sync"
"time"
"github.com/google/uuid"
)
// WorkerRegistry 维护当前在线的 Worker 池
type WorkerRegistry struct {
mu sync.RWMutex
workers map[string]*Worker
heartbeatTimeout time.Duration
}
func NewWorkerRegistry() *WorkerRegistry {
return &WorkerRegistry{
workers: make(map[string]*Worker),
heartbeatTimeout: 30 * time.Second,
}
}
// Register 注册一个新的 worker,返回分配的 ID
func (r *WorkerRegistry) Register(req *RegisterRequest, local bool) *Worker {
w := &Worker{
ID: "wk_" + uuid.NewString(),
Name: req.Name,
Version: req.Version,
Capacity: req.Capacity,
HWAccel: req.HWAccel,
CodecsDecode: req.CodecsDecode,
CodecsEncode: req.CodecsEncode,
MaxResolution: req.MaxResolution,
Tags: req.Tags,
Endpoint: req.Endpoint,
FreeSlots: req.Capacity,
LastSeen: time.Now(),
RegisteredAt: time.Now(),
Local: local,
}
if w.Capacity <= 0 {
w.Capacity = 1
w.FreeSlots = 1
}
r.mu.Lock()
r.workers[w.ID] = w
r.mu.Unlock()
return w
}
// Heartbeat 更新 worker 心跳信息,未注册返回 false
func (r *WorkerRegistry) Heartbeat(req *HeartbeatRequest) (*Worker, bool) {
r.mu.Lock()
defer r.mu.Unlock()
w, ok := r.workers[req.WorkerID]
if !ok {
return nil, false
}
w.LastSeen = time.Now()
w.Load = req.Load
w.Running = req.Running
w.FreeSlots = req.FreeSlots
return w, true
}
// Get 获取 worker
func (r *WorkerRegistry) Get(id string) (*Worker, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
w, ok := r.workers[id]
return w, ok
}
// Unregister 主动移除 worker
func (r *WorkerRegistry) Unregister(id string) {
r.mu.Lock()
delete(r.workers, id)
r.mu.Unlock()
}
// List 列出所有 worker(拷贝)
func (r *WorkerRegistry) List() []*Worker {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Worker, 0, len(r.workers))
for _, w := range r.workers {
out = append(out, w)
}
return out
}
// Sweep 清理超时未心跳的 worker;返回被移除的 worker_id 列表
func (r *WorkerRegistry) Sweep() []string {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
var removed []string
for id, w := range r.workers {
if w.Local {
continue // 本地 Worker 不通过心跳判活
}
if now.Sub(w.LastSeen) > r.heartbeatTimeout {
removed = append(removed, id)
delete(r.workers, id)
}
}
return removed
}
// HasAvailable 是否存在有空闲槽位的 worker
func (r *WorkerRegistry) HasAvailable() bool {
r.mu.RLock()
defer r.mu.RUnlock()
for _, w := range r.workers {
if w.FreeSlots > 0 {
return true
}
}
return false
}
-337
View File
@@ -1,337 +0,0 @@
package transcode
import (
"errors"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// Scheduler 任务调度器:维护 pending 队列并支持 worker pull 领取
type Scheduler struct {
mu sync.Mutex
jobs map[string]*Job // 全部任务(含完成)
pending []*Job // 待领取队列
// 等待新任务的 worker 通知
waiters []chan struct{}
// cancellation 待派发给 worker 的取消列表(worker_id -> job_ids)
pendingCancels map[string][]string
// onCancel 回调:Cancel 时同步通知本地 runner kill FFmpeg 进程
// 由 Manager 在启动时注入
onCancel func(jobID string)
registry *WorkerRegistry
}
func NewScheduler(reg *WorkerRegistry) *Scheduler {
return &Scheduler{
jobs: make(map[string]*Job),
pendingCancels: make(map[string][]string),
registry: reg,
}
}
// SetOnCancel 注入取消回调(由 Manager 在启动本地 worker 后调用)
func (s *Scheduler) SetOnCancel(fn func(jobID string)) {
s.mu.Lock()
s.onCancel = fn
s.mu.Unlock()
}
// Submit 提交一个任务并返回(带 ID + token)
func (s *Scheduler) Submit(j *Job) *Job {
if j.ID == "" {
j.ID = "job_" + uuid.NewString()
}
if j.CallbackToken == "" {
j.CallbackToken = "tk_" + uuid.NewString()
}
if j.readyCh == nil {
j.readyCh = make(chan struct{})
}
if j.CreatedAt.IsZero() {
j.CreatedAt = time.Now()
}
j.Status = JobPending
s.mu.Lock()
s.jobs[j.ID] = j
s.pending = append(s.pending, j)
// 唤醒等待的 worker
for _, ch := range s.waiters {
close(ch)
}
s.waiters = nil
s.mu.Unlock()
return j
}
// Get 通过 id 获取任务
func (s *Scheduler) Get(id string) (*Job, bool) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
return j, ok
}
// Claim 由 worker 调用领取任务(最多 slots 个);如果没有任务则等待 wait 秒
func (s *Scheduler) Claim(workerID string, slots int, wait time.Duration) []*Job {
worker, ok := s.registry.Get(workerID)
if !ok {
return nil
}
taken := s.claimNow(worker, slots)
if len(taken) > 0 || wait <= 0 {
return taken
}
// 长轮询等待
s.mu.Lock()
notify := make(chan struct{})
s.waiters = append(s.waiters, notify)
s.mu.Unlock()
select {
case <-notify:
case <-time.After(wait):
}
return s.claimNow(worker, slots)
}
func (s *Scheduler) claimNow(worker *Worker, slots int) []*Job {
s.mu.Lock()
defer s.mu.Unlock()
if slots <= 0 || len(s.pending) == 0 {
return nil
}
// 按打分排序候选任务,选最匹配的前 slots 个
candidates := append([]*Job(nil), s.pending...)
sort.SliceStable(candidates, func(i, j int) bool {
return scoreJobForWorker(candidates[i], worker) > scoreJobForWorker(candidates[j], worker)
})
var taken []*Job
for _, j := range candidates {
if len(taken) >= slots {
break
}
if !workerCanRun(worker, j) {
continue
}
j.mu.Lock()
j.Status = JobRunning
j.WorkerID = worker.ID
j.StartedAt = time.Now()
j.mu.Unlock()
taken = append(taken, j)
// 从 pending 中移除
for idx, pj := range s.pending {
if pj == j {
s.pending = append(s.pending[:idx], s.pending[idx+1:]...)
break
}
}
}
return taken
}
// scoreJobForWorker 给 (worker, job) 打分(数值越大越匹配)
func scoreJobForWorker(j *Job, w *Worker) float64 {
score := 1.0
// 硬件加速匹配 + 50
if len(j.Profiles) > 0 {
req := strings.ToLower(j.Profiles[0].HWAccel)
if req != "" && req != "none" && req != "auto" {
for _, hw := range w.HWAccel {
if strings.EqualFold(hw, req) {
score += 50
break
}
}
}
}
// 空闲槽位多 +
score += float64(w.FreeSlots) * 2
// 负载低 +
score += (1 - w.Load) * 10
return score
}
// workerCanRun 检查 worker 是否能运行该 job
func workerCanRun(w *Worker, j *Job) bool {
if w.FreeSlots <= 0 {
return false
}
if j.Probe.VideoCodec != "" && len(w.CodecsDecode) > 0 {
ok := false
for _, c := range w.CodecsDecode {
if strings.EqualFold(c, j.Probe.VideoCodec) {
ok = true
break
}
}
if !ok {
return false
}
}
return true
}
// Finish 由 worker 调用,标记任务完成/失败
func (s *Scheduler) Finish(req *FinishRequest) error {
s.mu.Lock()
j, ok := s.jobs[req.JobID]
s.mu.Unlock()
if !ok {
return errors.New("job not found")
}
j.mu.Lock()
defer j.mu.Unlock()
j.Status = req.Status
j.Error = req.Error
j.FinishedAt = time.Now()
// 任务一旦完成或失败,确保 readyCh 一定会被关闭,避免播放端死等
j.readyOnce.Do(func() { close(j.readyCh) })
return nil
}
// Cancel 主动取消任务(同时通知对应 worker 并 kill 本地 FFmpeg 进程)
func (s *Scheduler) Cancel(jobID string) {
s.mu.Lock()
j, ok := s.jobs[jobID]
if !ok {
s.mu.Unlock()
return
}
j.mu.Lock()
if j.Status == JobPending || j.Status == JobRunning || j.Status == JobReady {
j.Status = JobCancelled
}
j.readyOnce.Do(func() { close(j.readyCh) })
wid := j.WorkerID
j.mu.Unlock()
if wid != "" {
s.pendingCancels[wid] = append(s.pendingCancels[wid], jobID)
}
// 从 pending 中剔除
for idx, pj := range s.pending {
if pj.ID == jobID {
s.pending = append(s.pending[:idx], s.pending[idx+1:]...)
break
}
}
cancelFn := s.onCancel
s.mu.Unlock()
// 在锁外调用回调,避免死锁;回调会 kill 本地 FFmpeg 进程
if cancelFn != nil {
cancelFn(jobID)
}
}
// ListActiveJobs 列出所有活跃任务(pending/running/ready),用于空闲超时检测
func (s *Scheduler) ListActiveJobs() []*Job {
s.mu.Lock()
defer s.mu.Unlock()
var out []*Job
for _, j := range s.jobs {
st := j.GetStatus()
if st == JobRunning || st == JobReady || st == JobPending {
out = append(out, j)
}
}
return out
}
// PopCancellations 取出某个 worker 待处理的取消列表(心跳响应中下发)
func (s *Scheduler) PopCancellations(workerID string) []string {
s.mu.Lock()
defer s.mu.Unlock()
ids := s.pendingCancels[workerID]
delete(s.pendingCancels, workerID)
return ids
}
// ListJobs 列出全部任务(仅用于调试/管理)
func (s *Scheduler) ListJobs() []*Job {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*Job, 0, len(s.jobs))
for _, j := range s.jobs {
out = append(out, j)
}
return out
}
// Reactivate 把一个已 Cancelled / Failed / Finished 的 job 重新激活回 Pending 状态,
// 让 worker 能再次领取并跑转码。用于"播放端重新请求时按需重启转码"的场景。
//
// 设计点:
// 1. 复用原 jobID 和 callback_token,前端持有的 master.m3u8 中的 URL 不需要变更
// 2. 重置 readyCh / Status / WorkerID 等运行时字段,但保留 Path / SourceURL / Profiles / Probe
// 3. 重新加入 pending 队列并唤醒等待的 worker
//
// 返回被激活的 Job;如果 job 不存在返回 nil 和 false。
func (s *Scheduler) Reactivate(jobID string) (*Job, bool) {
s.mu.Lock()
j, ok := s.jobs[jobID]
if !ok {
s.mu.Unlock()
return nil, false
}
j.mu.Lock()
// 只有终止态才需要重新激活;Running/Ready 直接返回即可
switch j.Status {
case JobPending, JobRunning, JobReady:
j.mu.Unlock()
s.mu.Unlock()
return j, true
}
// 重置运行时状态
j.Status = JobPending
j.WorkerID = ""
j.Error = ""
j.FinishedAt = time.Time{}
j.LastAccessAt = time.Now()
// 重置 readyCh:原 channel 已 closed,需要全新一份给后续 WaitReady
j.readyCh = make(chan struct{})
j.readyOnce = sync.Once{}
j.mu.Unlock()
// 重新入 pending 队列(先确认不在队列中以免重复)
inQueue := false
for _, pj := range s.pending {
if pj.ID == jobID {
inQueue = true
break
}
}
if !inQueue {
s.pending = append(s.pending, j)
}
// 唤醒等待的 worker
for _, ch := range s.waiters {
close(ch)
}
s.waiters = nil
s.mu.Unlock()
return j, true
}
// PurgeBefore 删除 finishedAt 早于某时刻的任务,避免 jobs map 无限增长
// 返回被清理的 job ID 列表,调用方需联动清理 Cache
func (s *Scheduler) PurgeBefore(t time.Time) []string {
s.mu.Lock()
defer s.mu.Unlock()
var purged []string
for id, j := range s.jobs {
if (j.Status == JobFinished || j.Status == JobFailed || j.Status == JobCancelled) &&
!j.FinishedAt.IsZero() && j.FinishedAt.Before(t) {
delete(s.jobs, id)
purged = append(purged, id)
}
}
return purged
}
-169
View File
@@ -1,169 +0,0 @@
// Package transcode 实现 OpenList 的 FFmpeg 云端/本地转码功能。
// 设计目标:
// 1. 总开关默认关,开启后按文件大小、源码率、源编码、文件后缀过滤决定是否转码。
// 2. 协议自研,纯 HTTP/JSON,可挂接本地内置 Worker 或远程 Worker 节点池。
// 3. 切片流式回吐,支持边转边播。
package transcode
import (
"sync"
"time"
)
// JobStatus 转码任务状态
type JobStatus string
const (
JobPending JobStatus = "pending" // 等待 Worker 领取
JobRunning JobStatus = "running" // Worker 已领取并在转码
JobReady JobStatus = "ready" // 至少首切片可用
JobFinished JobStatus = "finished" // 全部切片完成
JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled"
)
// OutputFormat 输出封装格式
type OutputFormat string
const (
FormatHLS OutputFormat = "hls"
FormatDASH OutputFormat = "dash"
FormatMP4 OutputFormat = "mp4"
)
// HWAccel 硬件加速类型
type HWAccel string
const (
HWNone HWAccel = "none"
HWAuto HWAccel = "auto"
HWNVENC HWAccel = "nvenc" // NVIDIA
HWQSV HWAccel = "qsv" // Intel QuickSync
HWVAAPI HWAccel = "vaapi" // Linux VA-API
HWAMF HWAccel = "amf" // AMD AMF
HWVideoToolbox HWAccel = "videotoolbox" // macOS
)
// SourceProbe 源文件探测信息(由 ffprobe 得出)
type SourceProbe struct {
Duration float64 `json:"duration"` // 秒
Size int64 `json:"size"` // 字节
VideoCodec string `json:"video_codec"` // h264 / hevc / av1 / vp9 ...
VideoBitrate int64 `json:"video_bitrate"` // bps
Width int `json:"width"`
Height int `json:"height"`
AudioCodec string `json:"audio_codec"`
AudioBitrate int64 `json:"audio_bitrate"`
}
// Profile 输出转码档位
type Profile struct {
Name string `json:"name"` // 例如 1080p
VideoCodec string `json:"video_codec"` // h264 / hevc / av1
VideoBitrate string `json:"video_bitrate"` // 例如 4000k
Scale string `json:"scale"` // 例如 1920:-2
AudioCodec string `json:"audio_codec"` // aac / mp3 / opus / copy
AudioBitrate string `json:"audio_bitrate"` // 例如 160k
HWAccel string `json:"hwaccel"` // worker 端如何选择硬件加速
}
// OutputSpec 输出参数
type OutputSpec struct {
Format OutputFormat `json:"format"`
SegmentDuration int `json:"segment_duration"` // 秒
}
// Job 一次转码任务
type Job struct {
ID string `json:"id"`
Path string `json:"path"` // OpenList 文件路径
SourceURL string `json:"source_url"` // 提供给 Worker 的签名 URL
Probe SourceProbe `json:"probe"`
Profiles []Profile `json:"profiles"`
Output OutputSpec `json:"output"`
CallbackToken string `json:"callback_token"` // Worker 回吐切片用
Deadline int64 `json:"deadline"` // unix 秒
// 运行时
Status JobStatus `json:"status"`
WorkerID string `json:"worker_id"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
LastAccessAt time.Time `json:"last_access_at"` // 播放端最后一次请求 segment/playlist 的时间
Error string `json:"error,omitempty"`
// 内部:用于"边转边播"——首切片就绪后关闭该 channel
readyOnce sync.Once
readyCh chan struct{}
mu sync.RWMutex
}
// NewJob 构造任务(自动初始化内部 channel)
func NewJob() *Job {
now := time.Now()
return &Job{
Status: JobPending,
CreatedAt: now,
LastAccessAt: now,
readyCh: make(chan struct{}),
}
}
// Touch 更新最后访问时间(播放端每次请求 segment/playlist 时调用)
func (j *Job) Touch() {
j.mu.Lock()
j.LastAccessAt = time.Now()
j.mu.Unlock()
}
// GetLastAccess 获取最后访问时间(线程安全)
func (j *Job) GetLastAccess() time.Time {
j.mu.RLock()
defer j.mu.RUnlock()
return j.LastAccessAt
}
// GetStatus 获取当前状态(线程安全)
func (j *Job) GetStatus() JobStatus {
j.mu.RLock()
defer j.mu.RUnlock()
return j.Status
}
// MarkReady 标记任务已可播放(首切片就绪)
func (j *Job) MarkReady() {
j.readyOnce.Do(func() {
j.mu.Lock()
if j.Status == JobRunning || j.Status == JobPending {
j.Status = JobReady
}
j.mu.Unlock()
close(j.readyCh)
})
}
// WaitReady 阻塞等待首切片就绪
func (j *Job) WaitReady() <-chan struct{} { return j.readyCh }
// Worker 节点信息
type Worker struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Capacity int `json:"capacity"` // 最大并发任务数
HWAccel []string `json:"hwaccel"` // 支持的硬件加速
CodecsDecode []string `json:"codecs_decode"` // 可解码
CodecsEncode []string `json:"codecs_encode"` // 可编码
MaxResolution string `json:"max_resolution"` // 最大分辨率
Tags []string `json:"tags"`
Endpoint string `json:"endpoint,omitempty"` // 反向连接地址(可选)
// 运行时
Load float64 `json:"load"` // 0~1
Running []string `json:"running"` // 当前 job_ids
FreeSlots int `json:"free_slots"`
LastSeen time.Time `json:"last_seen"`
RegisteredAt time.Time `json:"registered_at"`
Local bool `json:"local"` // 是否本地内置 Worker
}
+49 -372
View File
@@ -1,365 +1,80 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package generic_sync
import (
"sync"
"sync/atomic"
"unsafe"
)
// MapOf is like a Go map[interface{}]interface{} but is safe for concurrent use
// by multiple goroutines without additional locking or coordination.
// Loads, stores, and deletes run in amortized constant time.
//
// The MapOf type is specialized. Most code should use a plain Go map instead,
// with separate locking or coordination, for better type safety and to make it
// easier to maintain other invariants along with the map content.
//
// The MapOf type is optimized for two common use cases: (1) when the entry for a given
// key is only ever written once but read many times, as in caches that only grow,
// or (2) when multiple goroutines read, write, and overwrite entries for disjoint
// sets of keys. In these two cases, use of a MapOf may significantly reduce lock
// contention compared to a Go map paired with a separate Mutex or RWMutex.
//
// The zero MapOf is empty and ready for use. A MapOf must not be copied after first use.
type MapOf[K comparable, V any] struct {
mu sync.Mutex
// read contains the portion of the map's contents that are safe for
// concurrent access (with or without mu held).
//
// The read field itself is always safe to load, but must only be stored with
// mu held.
//
// Entries stored in read may be updated concurrently without mu, but updating
// a previously-expunged entry requires that the entry be copied to the dirty
// map and unexpunged with mu held.
read atomic.Value // readOnly
// dirty contains the portion of the map's contents that require mu to be
// held. To ensure that the dirty map can be promoted to the read map quickly,
// it also includes all of the non-expunged entries in the read map.
//
// Expunged entries are not stored in the dirty map. An expunged entry in the
// clean map must be unexpunged and added to the dirty map before a new value
// can be stored to it.
//
// If the dirty map is nil, the next write to the map will initialize it by
// making a shallow copy of the clean map, omitting stale entries.
dirty map[K]*entry[V]
// misses counts the number of loads since the read map was last updated that
// needed to lock mu to determine whether the key was present.
//
// Once enough misses have occurred to cover the cost of copying the dirty
// map, the dirty map will be promoted to the read map (in the unamended
// state) and the next store to the map will make a new dirty copy.
misses int
sync.Map
}
// readOnly is an immutable struct stored atomically in the MapOf.read field.
type readOnly[K comparable, V any] struct {
m map[K]*entry[V]
amended bool // true if the dirty map contains some key not in m.
func (m *MapOf[K, V]) CompareAndDelete(key K, old V) (deleted bool) {
return m.Map.CompareAndDelete(key, old)
}
// expunged is an arbitrary pointer that marks entries which have been deleted
// from the dirty map.
var expunged = unsafe.Pointer(new(interface{}))
// An entry is a slot in the map corresponding to a particular key.
type entry[V any] struct {
// p points to the value stored for the entry.
//
// If p == nil, the entry has been deleted and m.dirty == nil.
//
// If p == expungedFor[V](), the entry has been deleted, m.dirty != nil, and
// the entry is missing from m.dirty.
//
// Otherwise, the entry is valid and recorded in m.read.m[key] and, if m.dirty
// != nil, in m.dirty[key].
//
// An entry can be deleted by atomic replacement with nil: when m.dirty is
// next created, it will atomically replace nil with expunged and leave
// m.dirty[key] unset.
//
// An entry's associated value can be updated by atomic replacement, provided
// p != expunged. If p == expungedFor[V](), an entry's associated value can be updated
// only after first setting m.dirty[key] = e so that lookups using the dirty
// map find the entry.
p atomic.Pointer[V]
func (m *MapOf[K, V]) CompareAndSwap(key K, old V, new V) (swapped bool) {
return m.Map.CompareAndSwap(key, old, new)
}
func expungedFor[V any]() *V {
return (*V)(expunged)
func (m *MapOf[K, V]) Delete(key K) {
m.Map.Delete(key)
}
func newEntry[V any](i V) *entry[V] {
e := &entry[V]{}
e.p.Store(&i)
return e
}
// Load returns the value stored in the map for a key, or nil if no
// value is present.
// The ok result indicates whether value was found in the map.
func (m *MapOf[K, V]) Load(key K) (value V, ok bool) {
read, _ := m.read.Load().(readOnly[K, V])
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
// Avoid reporting a spurious miss if m.dirty got promoted while we were
// blocked on m.mu. (If further loads of the same key will not miss, it's
// not worth copying the dirty map for this key.)
read, _ = m.read.Load().(readOnly[K, V])
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
// Regardless of whether the entry was present, record a miss: this key
// will take the slow path until the dirty map is promoted to the read
// map.
m.missLocked()
}
m.mu.Unlock()
v, ok := m.Map.Load(key)
if ok {
value = v.(V)
}
if !ok {
return value, false
return value, ok
}
func (m *MapOf[K, V]) LoadAndDelete(key K) (value V, loaded bool) {
v, loaded := m.Map.LoadAndDelete(key)
if loaded {
value = v.(V)
}
return e.load()
return value, loaded
}
func (m *MapOf[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) {
a, loaded := m.Map.LoadOrStore(key, value)
if loaded {
actual = a.(V)
}
return actual, loaded
}
func (m *MapOf[K, V]) Range(f func(key K, value V) bool) {
m.Map.Range(func(key, value any) bool {
return f(key.(K), value.(V))
})
}
func (m *MapOf[K, V]) Store(key K, value V) {
m.Map.Store(key, value)
}
func (m *MapOf[K, V]) Swap(key K, value V) (previous V, loaded bool) {
p, loaded := m.Map.Swap(key, value)
if loaded {
previous = p.(V)
}
return previous, loaded
}
func (m *MapOf[K, V]) Has(key K) bool {
_, ok := m.Load(key)
_, ok := m.Map.Load(key)
return ok
}
func (e *entry[V]) load() (value V, ok bool) {
p := e.p.Load()
if p == nil || p == expungedFor[V]() {
return value, false
}
return *(*V)(p), true
}
// Store sets the value for a key.
func (m *MapOf[K, V]) Store(key K, value V) {
read, _ := m.read.Load().(readOnly[K, V])
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock()
read, _ = m.read.Load().(readOnly[K, V])
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
// The entry was previously expunged, which implies that there is a
// non-nil dirty map and this entry is not in it.
m.dirty[key] = e
}
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {
e.storeLocked(&value)
} else {
if !read.amended {
// We're adding the first new key to the dirty map.
// Make sure it is allocated and mark the read-only map as incomplete.
m.dirtyLocked()
m.read.Store(readOnly[K, V]{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}
// tryStore stores a value if the entry has not been expunged.
//
// If the entry is expunged, tryStore returns false and leaves the entry
// unchanged.
func (e *entry[V]) tryStore(i *V) bool {
for {
p := e.p.Load()
if p == expungedFor[V]() {
return false
}
if e.p.CompareAndSwap(p, i) {
return true
}
}
}
// unexpungeLocked ensures that the entry is not marked as expunged.
//
// If the entry was previously expunged, it must be added to the dirty map
// before m.mu is unlocked.
func (e *entry[V]) unexpungeLocked() (wasExpunged bool) {
return e.p.CompareAndSwap(expungedFor[V](), nil)
}
// storeLocked unconditionally stores a value to the entry.
//
// The entry must be known not to be expunged.
func (e *entry[V]) storeLocked(i *V) {
e.p.Store(i)
}
// LoadOrStore returns the existing value for the key if present.
// Otherwise, it stores and returns the given value.
// The loaded result is true if the value was loaded, false if stored.
func (m *MapOf[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) {
// Avoid locking if it's a clean hit.
read, _ := m.read.Load().(readOnly[K, V])
if e, ok := read.m[key]; ok {
actual, loaded, ok := e.tryLoadOrStore(value)
if ok {
return actual, loaded
}
}
m.mu.Lock()
read, _ = m.read.Load().(readOnly[K, V])
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
m.dirty[key] = e
}
actual, loaded, _ = e.tryLoadOrStore(value)
} else if e, ok := m.dirty[key]; ok {
actual, loaded, _ = e.tryLoadOrStore(value)
m.missLocked()
} else {
if !read.amended {
// We're adding the first new key to the dirty map.
// Make sure it is allocated and mark the read-only map as incomplete.
m.dirtyLocked()
m.read.Store(readOnly[K, V]{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
actual, loaded = value, false
}
m.mu.Unlock()
return actual, loaded
}
// tryLoadOrStore atomically loads or stores a value if the entry is not
// expunged.
//
// If the entry is expunged, tryLoadOrStore leaves the entry unchanged and
// returns with ok==false.
func (e *entry[V]) tryLoadOrStore(i V) (actual V, loaded, ok bool) {
p := e.p.Load()
if p == expungedFor[V]() {
return actual, false, false
}
if p != nil {
return *(*V)(p), true, true
}
// Copy the value after the first load to make this method more amenable
// to escape analysis: if we hit the "load" path or the entry is expunged, we
// shouldn't bother heap-allocating.
ic := i
for {
if e.p.CompareAndSwap(nil, &ic) {
return i, false, true
}
p = e.p.Load()
if p == expungedFor[V]() {
return actual, false, false
}
if p != nil {
return *(*V)(p), true, true
}
}
}
// Delete deletes the value for a key.
func (m *MapOf[K, V]) Delete(key K) {
read, _ := m.read.Load().(readOnly[K, V])
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly[K, V])
e, ok = read.m[key]
if !ok && read.amended {
delete(m.dirty, key)
}
m.mu.Unlock()
}
if ok {
e.delete()
}
}
func (e *entry[V]) delete() (hadValue bool) {
for {
p := e.p.Load()
if p == nil || p == expungedFor[V]() {
return false
}
if e.p.CompareAndSwap(p, nil) {
return true
}
}
}
// Range calls f sequentially for each key and value present in the map.
// If f returns false, range stops the iteration.
//
// Range does not necessarily correspond to any consistent snapshot of the MapOf's
// contents: no key will be visited more than once, but if the value for any key
// is stored or deleted concurrently, Range may reflect any mapping for that key
// from any point during the Range call.
//
// Range may be O(N) with the number of elements in the map even if f returns
// false after a constant number of calls.
func (m *MapOf[K, V]) Range(f func(key K, value V) bool) {
// We need to be able to iterate over all of the keys that were already
// present at the start of the call to Range.
// If read.amended is false, then read.m satisfies that property without
// requiring us to hold m.mu for a long time.
read, _ := m.read.Load().(readOnly[K, V])
if read.amended {
// m.dirty contains keys not in read.m. Fortunately, Range is already O(N)
// (assuming the caller does not break out early), so a call to Range
// amortizes an entire copy of the map: we can promote the dirty copy
// immediately!
m.mu.Lock()
read, _ = m.read.Load().(readOnly[K, V])
if read.amended {
read = readOnly[K, V]{m: m.dirty}
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}
// Values returns a slice of the values in the map.
func (m *MapOf[K, V]) Values() []V {
var values []V
m.Range(func(key K, value V) bool {
values = append(values, value)
m.Map.Range(func(_, value any) bool {
values = append(values, value.(V))
return true
})
return values
}
func (m *MapOf[K, V]) Count() int {
return len(m.dirty)
count := 0
m.Map.Range(func(_, _ any) bool {
count++
return true
})
return count
}
func (m *MapOf[K, V]) Empty() bool {
@@ -376,43 +91,5 @@ func (m *MapOf[K, V]) ToMap() map[K]V {
}
func (m *MapOf[K, V]) Clear() {
m.Range(func(key K, value V) bool {
m.Delete(key)
return true
})
}
func (m *MapOf[K, V]) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(readOnly[K, V]{m: m.dirty})
m.dirty = nil
m.misses = 0
}
func (m *MapOf[K, V]) dirtyLocked() {
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly[K, V])
m.dirty = make(map[K]*entry[V], len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
func (e *entry[V]) tryExpungeLocked() (isExpunged bool) {
p := e.p.Load()
for p == nil {
if e.p.CompareAndSwap(nil, expungedFor[V]()) {
return true
}
p = e.p.Load()
}
return p == expungedFor[V]()
m.Map.Clear()
}
-74
View File
@@ -1,74 +0,0 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package generic_sync_test
import (
"math/rand"
"runtime"
"sync"
"testing"
"github.com/OpenListTeam/OpenList/v4/pkg/generic_sync"
)
func TestConcurrentRange(t *testing.T) {
const mapSize = 1 << 10
m := new(generic_sync.MapOf[int64, int64])
for n := int64(1); n <= mapSize; n++ {
m.Store(n, int64(n))
}
done := make(chan struct{})
var wg sync.WaitGroup
defer func() {
close(done)
wg.Wait()
}()
for g := int64(runtime.GOMAXPROCS(0)); g > 0; g-- {
r := rand.New(rand.NewSource(g))
wg.Add(1)
go func(g int64) {
defer wg.Done()
for i := int64(0); ; i++ {
select {
case <-done:
return
default:
}
for n := int64(1); n < mapSize; n++ {
if r.Int63n(mapSize) == 0 {
m.Store(n, n*i*g)
} else {
m.Load(n)
}
}
}
}(g)
}
iters := 1 << 10
if testing.Short() {
iters = 16
}
for n := iters; n > 0; n-- {
seen := make(map[int64]bool, mapSize)
m.Range(func(k, v int64) bool {
if v%k != 0 {
t.Fatalf("while Storing multiples of %v, Range saw value %v", k, v)
}
if seen[k] {
t.Fatalf("Range visited key %v twice", k)
}
seen[k] = true
return true
})
if len(seen) != mapSize {
t.Fatalf("Range visited %v elements of %v-element MapOf", len(seen), mapSize)
}
}
}
-8
View File
@@ -1,8 +0,0 @@
package task
import "errors"
var (
ErrTaskNotFound = errors.New("task not found")
ErrTaskRunning = errors.New("task is running")
)
-145
View File
@@ -1,145 +0,0 @@
package task
import (
"github.com/OpenListTeam/OpenList/v4/pkg/generic_sync"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
type Manager[K comparable] struct {
curID K
workerC chan struct{}
updateID func(*K)
tasks generic_sync.MapOf[K, *Task[K]]
}
func (tm *Manager[K]) Submit(task *Task[K]) K {
if tm.updateID != nil {
tm.updateID(&tm.curID)
task.ID = tm.curID
}
tm.tasks.Store(task.ID, task)
tm.do(task)
return task.ID
}
func (tm *Manager[K]) do(task *Task[K]) {
go func() {
log.Debugf("task [%s] waiting for worker", task.Name)
select {
case <-tm.workerC:
log.Debugf("task [%s] starting", task.Name)
task.run()
log.Debugf("task [%s] ended", task.Name)
case <-task.Ctx.Done():
log.Debugf("task [%s] canceled", task.Name)
return
}
// return worker
tm.workerC <- struct{}{}
}()
}
func (tm *Manager[K]) GetAll() []*Task[K] {
return tm.tasks.Values()
}
func (tm *Manager[K]) Get(tid K) (*Task[K], bool) {
return tm.tasks.Load(tid)
}
func (tm *Manager[K]) MustGet(tid K) *Task[K] {
task, _ := tm.Get(tid)
return task
}
func (tm *Manager[K]) Retry(tid K) error {
t, ok := tm.Get(tid)
if !ok {
return errors.WithStack(ErrTaskNotFound)
}
tm.do(t)
return nil
}
func (tm *Manager[K]) Cancel(tid K) error {
t, ok := tm.Get(tid)
if !ok {
return errors.WithStack(ErrTaskNotFound)
}
t.Cancel()
return nil
}
func (tm *Manager[K]) Remove(tid K) error {
t, ok := tm.Get(tid)
if !ok {
return errors.WithStack(ErrTaskNotFound)
}
if !t.Done() {
return errors.WithStack(ErrTaskRunning)
}
tm.tasks.Delete(tid)
return nil
}
// RemoveAll removes all tasks from the manager, this maybe shouldn't be used
// because the task maybe still running.
func (tm *Manager[K]) RemoveAll() {
tm.tasks.Clear()
}
func (tm *Manager[K]) RemoveByStates(states ...string) {
tasks := tm.GetAll()
for _, task := range tasks {
if utils.SliceContains(states, task.GetState()) {
_ = tm.Remove(task.ID)
}
}
}
func (tm *Manager[K]) GetByStates(states ...string) []*Task[K] {
var tasks []*Task[K]
tm.tasks.Range(func(key K, value *Task[K]) bool {
if utils.SliceContains(states, value.GetState()) {
tasks = append(tasks, value)
}
return true
})
return tasks
}
func (tm *Manager[K]) ListUndone() []*Task[K] {
return tm.GetByStates(PENDING, RUNNING, CANCELING)
}
func (tm *Manager[K]) ListDone() []*Task[K] {
return tm.GetByStates(SUCCEEDED, CANCELED, ERRORED)
}
func (tm *Manager[K]) ClearDone() {
tm.RemoveByStates(SUCCEEDED, CANCELED, ERRORED)
}
func (tm *Manager[K]) ClearSucceeded() {
tm.RemoveByStates(SUCCEEDED)
}
func (tm *Manager[K]) RawTasks() *generic_sync.MapOf[K, *Task[K]] {
return &tm.tasks
}
func NewTaskManager[K comparable](maxWorker int, updateID ...func(*K)) *Manager[K] {
tm := &Manager[K]{
tasks: generic_sync.MapOf[K, *Task[K]]{},
workerC: make(chan struct{}, maxWorker),
}
for i := 0; i < maxWorker; i++ {
tm.workerC <- struct{}{}
}
if len(updateID) > 0 {
tm.updateID = updateID[0]
}
return tm
}
-124
View File
@@ -1,124 +0,0 @@
// Package task manage task, such as file upload, file copy between storages, offline download, etc.
package task
import (
"context"
"runtime"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
var (
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
CANCELING = "canceling"
CANCELED = "canceled"
ERRORED = "errored"
)
type Func[K comparable] func(task *Task[K]) error
type Callback[K comparable] func(task *Task[K])
type Task[K comparable] struct {
ID K
Name string
state string // pending, running, finished, canceling, canceled, errored
status string
progress float64
Error error
Func Func[K]
callback Callback[K]
Ctx context.Context
cancel context.CancelFunc
}
func (t *Task[K]) SetStatus(status string) {
t.status = status
}
func (t *Task[K]) SetProgress(percentage float64) {
t.progress = percentage
}
func (t Task[K]) GetProgress() float64 {
return t.progress
}
func (t Task[K]) GetState() string {
return t.state
}
func (t Task[K]) GetStatus() string {
return t.status
}
func (t Task[K]) GetErrMsg() string {
if t.Error == nil {
return ""
}
return t.Error.Error()
}
func getCurrentGoroutineStack() string {
buf := make([]byte, 1<<16)
n := runtime.Stack(buf, false)
return string(buf[:n])
}
func (t *Task[K]) run() {
t.state = RUNNING
defer func() {
if err := recover(); err != nil {
log.Errorf("error [%s] while run task [%s],stack trace:\n%s", err, t.Name, getCurrentGoroutineStack())
t.Error = errors.Errorf("panic: %+v", err)
t.state = ERRORED
}
}()
t.Error = t.Func(t)
if t.Error != nil {
log.Errorf("error [%+v] while run task [%s]", t.Error, t.Name)
}
if errors.Is(t.Ctx.Err(), context.Canceled) {
t.state = CANCELED
} else if t.Error != nil {
t.state = ERRORED
} else {
t.state = SUCCEEDED
t.SetProgress(100)
if t.callback != nil {
t.callback(t)
}
}
}
func (t *Task[K]) retry() {
t.run()
}
func (t *Task[K]) Done() bool {
return t.state == SUCCEEDED || t.state == CANCELED || t.state == ERRORED
}
func (t *Task[K]) Cancel() {
if t.state == SUCCEEDED || t.state == CANCELED {
return
}
// maybe can't cancel
t.state = CANCELING
if t.cancel != nil {
t.cancel()
}
}
func WithCancelCtx[K comparable](task *Task[K]) *Task[K] {
ctx, cancel := context.WithCancel(context.Background())
task.Ctx = ctx
task.cancel = cancel
task.state = PENDING
return task
}
-99
View File
@@ -1,99 +0,0 @@
package task
import (
"sync/atomic"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/pkg/errors"
)
func TestTask_Manager(t *testing.T) {
var next atomic.Uint64
tm := NewTaskManager(3, func(id *uint64) {
*id = next.Add(1)
})
id := tm.Submit(WithCancelCtx(&Task[uint64]{
Name: "test",
Func: func(task *Task[uint64]) error {
time.Sleep(time.Millisecond * 500)
return nil
},
}))
task, ok := tm.Get(id)
if !ok {
t.Fatal("task not found")
}
time.Sleep(time.Millisecond * 100)
if task.state != RUNNING {
t.Errorf("task status not running: %s", task.state)
}
time.Sleep(time.Second)
if task.state != SUCCEEDED {
t.Errorf("task status not finished: %s", task.state)
}
}
func TestTask_Cancel(t *testing.T) {
var next atomic.Uint64
tm := NewTaskManager(3, func(id *uint64) {
*id = next.Add(1)
})
id := tm.Submit(WithCancelCtx(&Task[uint64]{
Name: "test",
Func: func(task *Task[uint64]) error {
for {
if utils.IsCanceled(task.Ctx) {
return nil
} else {
t.Logf("task is running")
}
}
},
}))
task, ok := tm.Get(id)
if !ok {
t.Fatal("task not found")
}
time.Sleep(time.Microsecond * 50)
task.Cancel()
time.Sleep(time.Millisecond)
if task.state != CANCELED {
t.Errorf("task status not canceled: %s", task.state)
}
}
func TestTask_Retry(t *testing.T) {
var next atomic.Uint64
tm := NewTaskManager(3, func(id *uint64) {
*id = next.Add(1)
})
num := 0
id := tm.Submit(WithCancelCtx(&Task[uint64]{
Name: "test",
Func: func(task *Task[uint64]) error {
num++
if num&1 == 1 {
return errors.New("test error")
}
return nil
},
}))
task, ok := tm.Get(id)
if !ok {
t.Fatal("task not found")
}
time.Sleep(time.Millisecond)
if task.Error == nil {
t.Error(task.state)
t.Fatal("task error is nil, but expected error")
} else {
t.Logf("task error: %s", task.Error)
}
task.retry()
time.Sleep(time.Millisecond)
if task.Error != nil {
t.Errorf("task error: %+v, but expected nil", task.Error)
}
}
+1
View File
@@ -0,0 +1 @@
## Put dist of frontend here.
+1 -3
View File
@@ -51,9 +51,7 @@ func Proxy(c *gin.Context) {
common.ErrorPage(c, err, 500)
return
}
// 支持 ?force 参数强制代理(用于媒体库等需要 JS 加载文件内容的场景,避免 CORS 问题)
_, forceProxy := c.GetQuery("force")
if forceProxy || canProxy(storage, filename) {
if canProxy(storage, filename) {
if _, ok := c.GetQuery("d"); !ok {
if url := common.GenerateDownProxyURL(storage.GetStorage(), rawPath); url != "" {
c.Redirect(302, url)
-982
View File
@@ -1,982 +0,0 @@
package handles
import (
"context"
"encoding/json"
"strconv"
"strings"
"sync"
"time"
stdpath "path"
"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/media"
"github.com/OpenListTeam/OpenList/v4/internal/media/scraper"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// scrapeConcurrency 刮削并发度(默认 5)
// 走 setting 读取,未配置时使用 5;远端 API 调用密集型任务,并发 5 在限流与吞吐间较平衡。
const defaultScrapeConcurrency = 5
// ==================== 配置管理 ====================
// ListMediaConfigs 获取所有媒体库配置
func ListMediaConfigs(c *gin.Context) {
types := []model.MediaType{
model.MediaTypeVideo,
model.MediaTypeMusic,
model.MediaTypeImage,
model.MediaTypeBook,
}
cfgs := make([]*model.MediaConfig, 0, len(types))
for _, t := range types {
cfg, err := db.GetMediaConfig(t)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
cfgs = append(cfgs, cfg)
}
common.SuccessResp(c, cfgs)
}
// SaveMediaConfigReq 保存配置请求
type SaveMediaConfigReq struct {
MediaType model.MediaType `json:"media_type" binding:"required"`
Enabled bool `json:"enabled"`
}
// SaveMediaConfig 保存媒体库配置
func SaveMediaConfig(c *gin.Context) {
var req SaveMediaConfigReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
cfg := &model.MediaConfig{
MediaType: req.MediaType,
Enabled: req.Enabled,
}
if err := db.SaveMediaConfig(cfg); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// ==================== 扫描路径管理 ====================
// ListMediaScanPaths 获取指定媒体类型的扫描路径列表
func ListMediaScanPaths(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
paths, err := db.ListMediaScanPaths(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, paths)
}
// CreateMediaScanPathReq 创建扫描路径请求
type CreateMediaScanPathReq struct {
MediaType model.MediaType `json:"media_type" binding:"required"`
Name string `json:"name"`
Path string `json:"path" binding:"required"`
PathMerge bool `json:"path_merge"`
TypeTag string `json:"type_tag"`
ContentTags string `json:"content_tags"`
EnableScrape bool `json:"enable_scrape"`
}
// CreateMediaScanPath 创建扫描路径
func CreateMediaScanPath(c *gin.Context) {
var req CreateMediaScanPathReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
if req.Path == "" {
req.Path = "/"
}
name := req.Name
if name == "" {
name = req.Path
}
sp := &model.MediaScanPath{
MediaType: req.MediaType,
Name: name,
Path: req.Path,
PathMerge: req.PathMerge,
TypeTag: req.TypeTag,
ContentTags: req.ContentTags,
EnableScrape: req.EnableScrape,
}
if err := db.CreateMediaScanPath(sp); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, sp)
}
// UpdateMediaScanPathReq 更新扫描路径请求
type UpdateMediaScanPathReq struct {
ID uint `json:"id" binding:"required"`
Name string `json:"name"`
Path string `json:"path"`
PathMerge bool `json:"path_merge"`
TypeTag string `json:"type_tag"`
ContentTags string `json:"content_tags"`
EnableScrape bool `json:"enable_scrape"`
}
// UpdateMediaScanPath 更新扫描路径
func UpdateMediaScanPath(c *gin.Context) {
var req UpdateMediaScanPathReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
sp, err := db.GetMediaScanPath(req.ID)
if err != nil {
common.ErrorResp(c, err, 404)
return
}
sp.Name = req.Name
sp.Path = req.Path
sp.PathMerge = req.PathMerge
sp.TypeTag = req.TypeTag
sp.ContentTags = req.ContentTags
sp.EnableScrape = req.EnableScrape
if err := db.UpdateMediaScanPath(sp); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// DeleteMediaScanPath 删除扫描路径
func DeleteMediaScanPath(c *gin.Context) {
idStr := c.Query("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
common.ErrorStrResp(c, "无效的ID", 400)
return
}
if err := db.DeleteMediaScanPath(uint(id)); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// ClearMediaScanPathDB 清空指定扫描路径的媒体数据
func ClearMediaScanPathDB(c *gin.Context) {
idStr := c.Query("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
common.ErrorStrResp(c, "无效的ID", 400)
return
}
if err := db.ClearMediaItemsByScanPath(uint(id)); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// ==================== 媒体条目管理(后台) ====================
// ListMediaItemsAdmin 后台分页查询媒体条目
func ListMediaItemsAdmin(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
keyword := c.Query("keyword")
orderBy := c.DefaultQuery("order_by", "name")
orderDir := c.DefaultQuery("order_dir", "asc")
scanPathIDStr := c.Query("scan_path_id")
var scanPathID uint
if scanPathIDStr != "" {
if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
scanPathID = uint(id)
}
}
q := db.MediaItemQuery{
MediaType: mediaType,
ScanPathID: scanPathID,
Keyword: keyword,
OrderBy: orderBy,
OrderDir: orderDir,
Page: page,
PageSize: pageSize,
}
items, total, err := db.ListMediaItems(q)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, common.PageResp{Content: items, Total: total})
}
// UpdateMediaItemReq 更新媒体条目请求
type UpdateMediaItemReq struct {
ID uint `json:"id" binding:"required"`
ScrapedName string `json:"scraped_name"`
Description string `json:"description"`
Cover string `json:"cover"`
ReleaseDate string `json:"release_date"`
Rating float32 `json:"rating"`
Genre string `json:"genre"`
Authors string `json:"authors"`
Plot string `json:"plot"`
Reviews string `json:"reviews"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
Publisher string `json:"publisher"`
ISBN string `json:"isbn"`
Hidden bool `json:"hidden"`
}
// UpdateMediaItemAdmin 后台更新媒体条目
func UpdateMediaItemAdmin(c *gin.Context) {
var req UpdateMediaItemReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
item, err := db.GetMediaItemByID(req.ID)
if err != nil {
common.ErrorResp(c, err, 404)
return
}
item.ScrapedName = req.ScrapedName
item.Description = req.Description
item.Cover = req.Cover
item.ReleaseDate = req.ReleaseDate
item.Rating = req.Rating
item.Genre = req.Genre
item.Authors = req.Authors
item.Plot = req.Plot
item.Reviews = req.Reviews
item.AlbumName = req.AlbumName
item.AlbumArtist = req.AlbumArtist
item.Publisher = req.Publisher
item.ISBN = req.ISBN
item.Hidden = req.Hidden
if err := db.UpdateMediaItem(item); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// DeleteMediaItemAdmin 后台删除媒体条目
func DeleteMediaItemAdmin(c *gin.Context) {
idStr := c.Query("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
common.ErrorStrResp(c, "无效的ID", 400)
return
}
if err := db.DeleteMediaItem(uint(id)); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// ClearMediaDB 清空指定类型媒体数据库
func ClearMediaDB(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
if mediaType == "" {
common.ErrorStrResp(c, "media_type 不能为空", 400)
return
}
if err := db.ClearMediaItems(mediaType); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// ClearMediaScrape 清空刮削数据(保留扫描记录,但清空所有刮削结果字段)
// 参数:media_type 可选,留空表示所有类型;item_id 暂不支持(统一为类型/全部)
func ClearMediaScrape(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
affected, err := db.ClearMediaScrapedData(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
// 同步清掉对应媒体配置中的最近刮削时间
if mediaType != "" {
if cfg, e := db.GetMediaConfig(mediaType); e == nil && cfg != nil {
cfg.LastScrapeAt = nil
_ = db.SaveMediaConfig(cfg)
}
} else {
if cfgs, e := db.GetAllMediaConfigs(); e == nil {
for i := range cfgs {
cfgs[i].LastScrapeAt = nil
_ = db.SaveMediaConfig(&cfgs[i])
}
}
}
common.SuccessResp(c, gin.H{"affected": affected})
}
// DeleteInvalidMedia 删除已失效的媒体条目(即对应文件 / 文件夹在存储中已不存在)
// 参数:media_type 可选,留空表示扫描所有类型
// 返回:检测总数、被删除条目数
func DeleteInvalidMedia(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
items, err := db.ListAllValidMediaItemsForCheck(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var invalidIDs []uint
for i := range items {
it := &items[i]
// 拼接文件 / 文件夹的完整 VFS 路径
// - 文件夹模式 (is_folder=true): folder_path 即文件夹自身(扫描根 + 文件夹名 视情况而定);这里直接使用 folder_path/file_name
// - 普通文件: folder_path 是所在目录,file_name 是文件名
fullPath := stdpath.Join(it.FolderPath, it.FileName)
if fullPath == "" || fullPath == "/" {
continue
}
if _, gerr := fs.Get(ctx, fullPath, &fs.GetArgs{NoLog: true}); gerr != nil {
invalidIDs = append(invalidIDs, it.ID)
}
}
if len(invalidIDs) > 0 {
if err := db.DeleteMediaItemsByIDs(invalidIDs); err != nil {
common.ErrorResp(c, err, 500)
return
}
}
log.Infof("删除已失效媒体条目:检测 %d 条,删除 %d 条 (media_type=%q)", len(items), len(invalidIDs), mediaType)
common.SuccessResp(c, gin.H{
"checked": len(items),
"deleted": len(invalidIDs),
})
}
// ==================== 扫描与刮削 ====================
// ScanMediaReq 扫描请求
type ScanMediaReq struct {
MediaType model.MediaType `json:"media_type" binding:"required"`
ScanPathID uint `json:"scan_path_id"` // 0 表示扫描全部路径
}
// StartMediaScan 开始扫描
func StartMediaScan(c *gin.Context) {
var req ScanMediaReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
cfg, err := db.GetMediaConfig(req.MediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
if !cfg.Enabled {
common.ErrorStrResp(c, "该媒体库未启用", 400)
return
}
if req.ScanPathID > 0 {
// 扫描单个路径
sp, err := db.GetMediaScanPath(req.ScanPathID)
if err != nil {
common.ErrorResp(c, err, 404)
return
}
media.ScanMediaPath(sp)
} else {
// 扫描全部路径
media.ScanMedia(cfg)
}
common.SuccessResp(c)
}
// GetMediaScanProgress 获取扫描进度
func GetMediaScanProgress(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
if mediaType == "" {
common.ErrorStrResp(c, "media_type 不能为空", 400)
return
}
progress := media.GetProgress(mediaType)
common.SuccessResp(c, progress)
}
// MediaScrapeStatsResp 刮削统计响应
// 包含 TMDB 视频刮削的累计指标,用于运维观测与命中率评估
type MediaScrapeStatsResp struct {
TMDB scraper.TMDBStats `json:"tmdb"`
}
// GetMediaScrapeStats 获取刮削统计(进程级累计值,重启清零)
// 返回字段:
// - total_scraped : 累计触发刮削次数
// - hit_count : 命中(含强命中)次数
// - low_confidence_count : 仅低置信度兜底命中次数
// - no_match_count : 完全未命中次数
// - total_attempts : 累计 TMDB 搜索请求次数
// - total_search_millis : 累计搜索耗时(毫秒)
func GetMediaScrapeStats(c *gin.Context) {
common.SuccessResp(c, MediaScrapeStatsResp{
TMDB: scraper.GetTMDBStats(),
})
}
// ScrapeMediaReq 刮削请求
type ScrapeMediaReq struct {
MediaType model.MediaType `json:"media_type" binding:"required"`
ItemID uint `json:"item_id"` // 0 表示刮削全部未刮削的
}
// StartMediaScrape 开始刮削
func StartMediaScrape(c *gin.Context) {
var req ScrapeMediaReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
cfg, err := db.GetMediaConfig(req.MediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
// 从系统设置中读取刮削配置
tmdbKey := setting.GetStr(conf.MediaTMDBKey)
tmdbAPIURL := setting.GetStr(conf.MediaTMDBAPIURL, "https://api.themoviedb.org")
discogsToken := setting.GetStr(conf.MediaDiscogsToken)
discogsAPIURL := setting.GetStr(conf.MediaDiscogsAPIURL, "https://api.discogs.com")
thumbnailMode := setting.GetStr(conf.MediaThumbnailMode, "base64")
thumbnailPath := setting.GetStr(conf.MediaThumbnailPath, "/imgs")
storeThumbnail := setting.GetBool(conf.MediaStoreThumbnail)
var preloadedItems []model.MediaItem
if req.ItemID > 0 {
item, e := db.GetMediaItemByID(req.ItemID)
if e != nil {
common.ErrorResp(c, e, 404)
return
}
preloadedItems = []model.MediaItem{*item}
}
go func() {
var items []model.MediaItem
var err error
if req.ItemID > 0 {
items = preloadedItems
} else {
// limit 传 -1,GORM 不会拼接 LIMIT 子句,从而一次性取出全部未刮削条目
items, err = db.GetUnscrappedItems(req.MediaType, -1)
if err != nil {
log.Errorf("获取未刮削条目失败: %v", err)
return
}
}
// 读取并发度(默认5),并启动 worker pool
concurrency := setting.GetInt(conf.MediaScrapeConcurrency, defaultScrapeConcurrency)
if concurrency <= 0 {
concurrency = defaultScrapeConcurrency
}
if concurrency > len(items) && len(items) > 0 {
concurrency = len(items)
}
var wg sync.WaitGroup
jobs := make(chan int, len(items))
// 落库串行化:所有 worker 把刮削成功的 item 推到 saveCh,由单协程负责落库,
// 这样既能并发抓 API、又避免多 goroutine 同时写 sqlite/mysql 引发锁竞争。
saveCh := make(chan *model.MediaItem, concurrency*2)
var saveWg sync.WaitGroup
// 单写入协程:刮一个写一个
saveWg.Add(1)
go func() {
defer saveWg.Done()
for it := range saveCh {
if err := db.UpdateMediaItem(it); err != nil {
log.Warnf("保存刮削结果失败 [id=%d, %s/%s]: %v", it.ID, it.FolderPath, it.FileName, err)
}
}
}()
worker := func() {
defer wg.Done()
for idx := range jobs {
item := &items[idx]
var scrapeErr error
switch req.MediaType {
case model.MediaTypeVideo:
s := scraper.NewTMDBScraper(tmdbKey, tmdbAPIURL)
scrapeErr = s.ScrapeVideo(item)
case model.MediaTypeMusic:
// 音乐:刮削前先用文件本身的 ID3/Vorbis tag + .lrc 做本地兜底,
// 仅填空字段;这样用户清空刮削后重新刮削也能恢复封面/歌词等。
localCtx, localCancel := context.WithTimeout(context.Background(), 30*time.Second)
media.FillMusicFromLocal(localCtx, item)
localCancel()
s := scraper.NewDiscogsScraper(discogsToken, discogsAPIURL)
scrapeErr = s.ScrapeMusic(item)
case model.MediaTypeBook:
doubanScraper := scraper.NewDoubanScraperWithConfig(
thumbnailMode,
thumbnailPath,
)
doubanErr := doubanScraper.ScrapeBook(item)
if doubanErr != nil {
log.Debugf("豆瓣刮削失败 [%s/%s]: %v,将尝试本地提取封面", item.FolderPath, item.FileName, doubanErr)
}
if item.Cover == "" {
bookCtx, bookCancel := context.WithTimeout(context.Background(), 60*time.Second)
bookFilePath := item.FolderPath + "/" + item.FileName
bookReader := media.FetchFileReader(bookCtx, bookFilePath)
if bookReader != nil {
localScraper := scraper.NewBookLocalScraperWithConfig(
thumbnailMode,
thumbnailPath,
)
if localCover := localScraper.ExtractLocalCover(item.FileName, bookFilePath, bookReader); localCover != "" {
item.Cover = localCover
}
_ = bookReader.Close()
}
bookCancel()
}
if doubanErr != nil && item.Cover == "" {
scrapeErr = doubanErr
}
case model.MediaTypeImage:
imgCtx, imgCancel := context.WithTimeout(context.Background(), 30*time.Second)
imgFilePath := item.FolderPath + "/" + item.FileName
imgReader := media.FetchFileReader(imgCtx, imgFilePath)
s := scraper.NewImageScraperWithConfig(
storeThumbnail,
thumbnailMode,
thumbnailPath,
)
scrapeErr = s.ScrapeImage(item, imgReader)
if imgReader != nil {
_ = imgReader.Close()
}
imgCancel()
}
if scrapeErr != nil {
log.Warnf("刮削失败 [%s] %s: %v", req.MediaType, item.FolderPath, scrapeErr)
// 即使远端刮削失败,只要本地兜底已经写入了任意有效字段,
// 也保存一次(不标记 ScrapedAt,便于下次还能重试),
// 避免 cover/lyrics 等被丢弃。
if hasAnyScrapedField(item) {
saveCh <- item
}
continue
}
now := time.Now()
item.ScrapedAt = &now
saveCh <- item
}
}
for i := 0; i < concurrency; i++ {
wg.Add(1)
go worker()
}
for i := range items {
jobs <- i
}
close(jobs)
wg.Wait()
close(saveCh)
saveWg.Wait()
log.Infof("刮削完成 [%s],共处理 %d 条(并发=%d)", req.MediaType, len(items), concurrency)
}()
_ = cfg
common.SuccessResp(c)
}
// hasAnyScrapedField 判断 item 是否已经有任意一个有意义的刮削字段被填充
// 用于:远端刮削失败但本地兜底已经写入了内容时,仍然落库保留这些信息
func hasAnyScrapedField(item *model.MediaItem) bool {
if item == nil {
return false
}
return item.Cover != "" ||
item.Lyrics != "" ||
item.Description != "" ||
item.Plot != "" ||
item.Genre != "" ||
item.Authors != "" ||
item.AlbumArtist != "" ||
item.ReleaseDate != "" ||
item.Rating > 0 ||
item.Publisher != "" ||
item.ISBN != "" ||
item.ExternalID != ""
}
// ==================== 公开API(前端媒体库浏览) ====================
// PublicListMedia 公开媒体列表(前端浏览用)
func PublicListMedia(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40"))
orderBy := c.DefaultQuery("order_by", "name")
orderDir := c.DefaultQuery("order_dir", "asc")
folderPath := c.Query("folder_path")
keyword := c.Query("keyword")
typeTag := c.Query("type_tag")
contentTag := c.Query("content_tag")
scanPathIDStr := c.Query("scan_path_id")
var scanPathID uint
if scanPathIDStr != "" {
if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
scanPathID = uint(id)
}
}
hidden := false
q := db.MediaItemQuery{
MediaType: mediaType,
ScanPathID: scanPathID,
FolderPath: folderPath,
TypeTag: typeTag,
ContentTag: contentTag,
Hidden: &hidden,
Keyword: keyword,
OrderBy: orderBy,
OrderDir: orderDir,
Page: page,
PageSize: pageSize,
}
items, total, err := db.ListMediaItems(q)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, common.PageResp{Content: items, Total: total})
}
// PublicGetMedia 公开获取媒体详情
func PublicGetMedia(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
common.ErrorStrResp(c, "无效的ID", 400)
return
}
item, err := db.GetMediaItemByID(uint(id))
if err != nil {
common.ErrorResp(c, err, 404)
return
}
if item.Hidden {
common.ErrorStrResp(c, "资源不存在", 404)
return
}
common.SuccessResp(c, item)
}
// PublicListAlbums 公开专辑列表(音乐)
func PublicListAlbums(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40"))
keyword := c.Query("keyword")
scanPathIDStr := c.Query("scan_path_id")
var scanPathID uint
if scanPathIDStr != "" {
if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
scanPathID = uint(id)
}
}
hidden := false
q := db.MediaItemQuery{
MediaType: model.MediaTypeMusic,
ScanPathID: scanPathID,
Hidden: &hidden,
Keyword: keyword,
Page: page,
PageSize: pageSize,
}
albums, total, err := db.ListAlbums(q)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, common.PageResp{Content: albums, Total: total})
}
// PublicGetAlbum 公开获取专辑详情及曲目
func PublicGetAlbum(c *gin.Context) {
albumName := c.Query("album_name")
albumArtist := c.Query("album_artist")
if albumName == "" && albumArtist == "" {
common.ErrorStrResp(c, "album_name 和 album_artist 不能同时为空", 400)
return
}
tracks, err := db.GetAlbumTracks(albumName, albumArtist)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, tracks)
}
// PublicListFolders 公开文件夹列表(目录浏览模式)
func PublicListFolders(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
if mediaType == "" {
common.ErrorStrResp(c, "media_type 不能为空", 400)
return
}
paths, err := db.ListFolderPaths(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, paths)
}
// PublicListScanPaths 公开获取扫描路径列表(前端筛选用)
func PublicListScanPaths(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
paths, err := db.ListMediaScanPaths(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, paths)
}
// ==================== 导入 / 导出 ====================
// MediaExportData 导出数据结构
// 包含版本号便于后续兼容性升级;导出粒度由 scope 字段说明:
// - "all" :所有类型的全部数据
// - "media_type" :单个媒体类型(video/music/image/book)的全部数据
// - "scan_path" :单个扫描路径下的数据(仅 1 个路径 + 该路径下的条目)
type MediaExportData struct {
Version int `json:"version"`
ExportAt time.Time `json:"export_at"`
Scope string `json:"scope"`
MediaType model.MediaType `json:"media_type,omitempty"`
ScanPaths []model.MediaScanPath `json:"scan_paths"`
Items []model.MediaItem `json:"items"`
}
const mediaExportVersion = 1
// ExportMediaDB 导出媒体数据
// query: media_type(可选,为空表示全部类型), scan_path_id(可选,单路径导出)
// 返回 JSON 文件下载流
func ExportMediaDB(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
scanPathIDStr := c.Query("scan_path_id")
export := MediaExportData{
Version: mediaExportVersion,
ExportAt: time.Now(),
}
if scanPathIDStr != "" {
// 按扫描路径导出
id, err := strconv.ParseUint(scanPathIDStr, 10, 64)
if err != nil {
common.ErrorStrResp(c, "无效的 scan_path_id", 400)
return
}
sp, err := db.GetMediaScanPath(uint(id))
if err != nil {
common.ErrorResp(c, err, 404)
return
}
items, err := db.ListMediaItemsByScanPath(uint(id))
if err != nil {
common.ErrorResp(c, err, 500)
return
}
export.Scope = "scan_path"
export.MediaType = sp.MediaType
export.ScanPaths = []model.MediaScanPath{*sp}
export.Items = items
} else if mediaType != "" {
// 按媒体类型导出
paths, err := db.ListMediaScanPaths(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
items, err := db.ListAllMediaItems(mediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
export.Scope = "media_type"
export.MediaType = mediaType
export.ScanPaths = paths
export.Items = items
} else {
// 全部导出
paths, err := db.ListAllMediaScanPaths()
if err != nil {
common.ErrorResp(c, err, 500)
return
}
items, err := db.ListAllMediaItems("")
if err != nil {
common.ErrorResp(c, err, 500)
return
}
export.Scope = "all"
export.ScanPaths = paths
export.Items = items
}
// 直接以 JSON 流下载
filename := buildExportFilename(export)
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
c.Header("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(c.Writer).Encode(export); err != nil {
log.Errorf("导出媒体数据失败: %v", err)
}
}
// buildExportFilename 根据导出范围生成下载文件名
func buildExportFilename(d MediaExportData) string {
ts := d.ExportAt.Format("20060102_150405")
switch d.Scope {
case "scan_path":
spName := ""
if len(d.ScanPaths) > 0 {
spName = d.ScanPaths[0].Name
if spName == "" {
spName = d.ScanPaths[0].Path
}
}
// 去掉路径中的斜杠等字符,避免文件名非法
safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "?", "_", "*", "_", "\"", "_", "<", "_", ">", "_", "|", "_").Replace(spName)
return "media_export_" + string(d.MediaType) + "_" + safe + "_" + ts + ".json"
case "media_type":
return "media_export_" + string(d.MediaType) + "_" + ts + ".json"
default:
return "media_export_all_" + ts + ".json"
}
}
// ImportMediaDB 导入媒体数据
// 支持两种调用方式:
// 1. multipart/form-data:file 字段携带导出生成的 JSON 文件
// 2. application/json:直接 POST 整个 MediaExportData 结构
//
// 可选 query 参数 scan_path_id:当导入"scan_path"范围数据时,
// 把所有条目重定向到该扫描路径ID(覆盖文件中原有的 scan_path_id)。
func ImportMediaDB(c *gin.Context) {
var data MediaExportData
contentType := c.GetHeader("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
fileHeader, err := c.FormFile("file")
if err != nil {
common.ErrorStrResp(c, "缺少 file 文件参数", 400)
return
}
f, err := fileHeader.Open()
if err != nil {
common.ErrorResp(c, err, 400)
return
}
defer f.Close()
if err := json.NewDecoder(f).Decode(&data); err != nil {
common.ErrorStrResp(c, "导入文件解析失败: "+err.Error(), 400)
return
}
} else {
if err := c.ShouldBindJSON(&data); err != nil {
common.ErrorResp(c, err, 400)
return
}
}
if data.Version <= 0 || data.Version > mediaExportVersion {
common.ErrorStrResp(c, "不支持的导入文件版本", 400)
return
}
// 1) 先导入扫描路径,建立 oldID -> newID 的映射
idMap, spCreated, spUpdated, err := db.ImportMediaScanPaths(data.ScanPaths)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
// 2) 再导入条目,根据 idMap 把 scan_path_id 重定向到新ID
// 修正每个条目的 scan_path_id:
// - 如果 query 指定了 scan_path_id,全部覆盖为该值(按扫描路径重定向导入)
// - 否则按 idMap 自动重定向;找不到映射的保留原值(通常发生在该路径未一并导入时)
scanPathIDStr := c.Query("scan_path_id")
var override *uint
if scanPathIDStr != "" {
if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil {
v := uint(id)
override = &v
}
}
if override == nil {
for i := range data.Items {
if newID, ok := idMap[data.Items[i].ScanPathID]; ok {
data.Items[i].ScanPathID = newID
}
}
}
itemCreated, itemUpdated, err := db.ImportMediaItems(data.Items, override)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, gin.H{
"scan_paths_created": spCreated,
"scan_paths_updated": spUpdated,
"items_created": itemCreated,
"items_updated": itemUpdated,
})
}
-462
View File
@@ -1,462 +0,0 @@
package handles
import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/internal/sign"
"github.com/OpenListTeam/OpenList/v4/internal/transcode"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
)
// ============================================================
// 播放端:/api/transcode/play 发起或复用一个转码任务
// ============================================================
// TranscodePlay 入口:POST /api/fs/transcode body: {path}
//
// 行为:
// 1. 检查 transcode_enabled
// 2. fs.Get 获取文件并跑 Decide
// 3. 不需要转码 → 返回 {transcode:false, direct: true}
// 4. 需要转码 → 创建 Job 入队,返回 {transcode:true, job_id, master_url}
func TranscodePlay(c *gin.Context) {
if !transcode.IsEnabled() {
common.SuccessResp(c, gin.H{"transcode": false, "reason": "disabled"})
return
}
var req struct {
Path string `json:"path" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
obj, err := fs.Get(c.Request.Context(), req.Path, &fs.GetArgs{NoLog: true})
if err != nil {
common.ErrorResp(c, err, 500)
return
}
if obj.IsDir() {
common.ErrorStrResp(c, "path is a directory", 400)
return
}
dec := transcode.Decide(transcode.DecideRequest{
FileName: obj.GetName(),
FileSize: obj.GetSize(),
})
if !dec.NeedTranscode {
common.SuccessResp(c, gin.H{
"transcode": false,
"reason": dec.Reason,
})
return
}
mgr := transcode.Default()
mgr.Start()
// 【内存优化】优先尝试解析为本地文件路径,让 ffmpeg 直接读取本地文件,
// 完全绕过 HTTP /d 代理路径——避免 net.Downloader 给每个 range 请求分配
// 高达 MaxBufferLimit (默认系统内存 5%) 的大缓冲。这是降低 Go 进程内存
// 占用最有效的方法。如果不是本地驱动则退回到 HTTP 签名 URL。
apiURL := common.GetApiUrlFromRequest(c.Request)
var sourceURL string
if localFile, ok := resolveLocalFilePath(req.Path); ok {
// ffmpeg 接受本地路径作为输入,无需 file:// 前缀
sourceURL = localFile
fmt.Printf("[transcode] using local file path for ffmpeg: %s\n", localFile)
} else {
// 远程驱动:构造签名 HTTP URL
signedPath := sign.Sign(req.Path)
sourceURL = fmt.Sprintf("%s/d%s?sign=%s", apiURL, encodePath(req.Path), signedPath)
}
// 创建 Job
job := transcode.NewJob()
job.Path = req.Path
job.SourceURL = sourceURL
// 【关键】探测视频总时长(异步会让首响应变慢,但只有一次,必须等待)
// 总时长用于生成完整的 playlist,避免 HLS.js 时长显示错误和拖动跳跃问题
probe, perr := transcode.ProbeSource(sourceURL)
if perr != nil {
// 探测失败不阻塞流程,但记录日志;后续 playlist 会缺少总时长信息
fmt.Printf("[transcode] probe source failed: %v\n", perr)
probe = transcode.SourceProbe{Size: obj.GetSize()}
} else if probe.Size == 0 {
probe.Size = obj.GetSize()
}
job.Probe = probe
profile := transcode.BuildProfile()
job.Profiles = []transcode.Profile{profile}
job.Output = transcode.BuildOutputSpec()
timeoutMin := setting.GetInt(conf.TranscodeJobTimeoutMin, 120)
job.Deadline = time.Now().Add(time.Duration(timeoutMin) * time.Minute).Unix()
mgr.Scheduler.Submit(job)
masterURL := fmt.Sprintf("%s/tc/%s/%s/master.m3u8", apiURL, job.ID, job.CallbackToken)
common.SuccessResp(c, gin.H{
"transcode": true,
"job_id": job.ID,
"master_url": masterURL,
"profile": profile.Name,
})
}
func encodePath(p string) string {
// 与 down.go 保持一致:保留 '/' 不转义
parts := strings.Split(p, "/")
for i, s := range parts {
parts[i] = url.PathEscape(s)
}
return strings.Join(parts, "/")
}
// resolveLocalFilePath 尝试把 OpenList 路径解析为宿主机文件系统的实际路径。
// 仅对本地驱动 (Local) 有效,其他驱动(云盘等)返回 false。
//
// 这是内存优化的关键路径:本地文件场景下让 ffmpeg 直接读文件,可以完全绕过
// HTTP /d 代理路径上的 net.Downloader 大块缓冲(默认每个 range 请求最高
// 占用 MaxBufferLimit = 系统内存 5%,多 chunk 并发 + 多 range 累计可达数 GB)。
func resolveLocalFilePath(rawPath string) (string, bool) {
storage, actualPath, err := op.GetStorageAndActualPath(rawPath)
if err != nil || storage == nil {
return "", false
}
if storage.Config().Name != "Local" {
return "", false
}
rooter, ok := storage.(driver.IRootPath)
if !ok {
return "", false
}
root := rooter.GetRootPath()
if root == "" {
return "", false
}
full := filepath.Join(root, actualPath)
// 只接受真实存在的文件,避免传给 ffmpeg 一个无效路径
fi, err := os.Stat(full)
if err != nil || fi.IsDir() {
return "", false
}
return full, true
}
// ============================================================
// 播放端:/tc/:job/:token/... Master/Variant playlist & 切片
// ============================================================
// TCMaster GET /tc/:job/:token/master.m3u8
func TCMaster(c *gin.Context) {
jobID := c.Param("job")
token := c.Param("token")
job, ok := transcode.Default().Scheduler.Get(jobID)
if !ok {
c.String(http.StatusNotFound, "job not found")
return
}
if job.CallbackToken != token {
c.String(http.StatusForbidden, "bad token")
return
}
if len(job.Profiles) == 0 {
c.String(http.StatusInternalServerError, "no profiles")
return
}
apiURL := common.GetApiUrlFromRequest(c.Request)
baseURL := fmt.Sprintf("%s/tc/%s/%s/", apiURL, jobID, token)
body := transcode.Default().Cache.MasterPlaylist(job.Profiles, baseURL)
job.Touch()
c.Header("Content-Type", "application/vnd.apple.mpegurl")
// Master playlist 内容在 job 生命周期内不变,设置较长缓存
c.Header("Cache-Control", "public, max-age=3600")
c.String(http.StatusOK, body)
}
// TCPlaylist GET /tc/:job/:token/:profile/playlist.m3u8
// 等待首切片就绪后返回;阻塞最多 30s
func TCPlaylist(c *gin.Context) {
jobID := c.Param("job")
token := c.Param("token")
profile := c.Param("profile")
mgr := transcode.Default()
job, ok := mgr.Scheduler.Get(jobID)
if !ok {
c.String(http.StatusNotFound, "job not found")
return
}
if job.CallbackToken != token {
c.String(http.StatusForbidden, "bad token")
return
}
// 【关键】如果 job 之前因 idle 被 cancel,这里重新激活:复用 jobID/token 重新入队
// 让 worker 再次启动 ffmpeg 转码,避免播放器看到 404 后无法恢复
if st := job.GetStatus(); st == transcode.JobCancelled || st == transcode.JobFinished || st == transcode.JobFailed {
if _, ok := mgr.Scheduler.Reactivate(jobID); ok {
fmt.Printf("[transcode] reactivate job %s on playlist request (was %s)\n", jobID, st)
}
}
job.Touch()
// 等首切片
select {
case <-job.WaitReady():
case <-time.After(30 * time.Second):
c.String(http.StatusGatewayTimeout, "timeout waiting first segment")
return
}
if job.Status == transcode.JobFailed {
c.String(http.StatusInternalServerError, "transcode failed: "+job.Error)
return
}
apiURL := common.GetApiUrlFromRequest(c.Request)
baseURL := fmt.Sprintf("%s/tc/%s/%s/%s/", apiURL, jobID, token, profile)
isFinished := job.Status == transcode.JobFinished
// 【关键】传入总时长,让 BuildPlaylist 能够生成完整切片列表(VOD 模式),
// 这样 HLS.js 就知道视频真实总时长,避免时长显示错误和拖动跳跃问题。
body := transcode.Default().Cache.BuildPlaylist(jobID, profile, job.Output.SegmentDuration,
isFinished, baseURL, job.Probe.Duration)
c.Header("Content-Type", "application/vnd.apple.mpegurl")
if isFinished || job.Probe.Duration > 0 {
// VOD 模式:playlist 完整不变,长缓存
c.Header("Cache-Control", "public, max-age=3600")
} else {
// 没有总时长时退回 EVENT 模式:短缓存
c.Header("Cache-Control", "public, max-age=2")
}
c.String(http.StatusOK, body)
}
// TCSegment GET /tc/:job/:token/:profile/seg-:seq.ts
// 切片就绪则直接返回;否则等待最多 60s
func TCSegment(c *gin.Context) {
jobID := c.Param("job")
token := c.Param("token")
profile := c.Param("profile")
segName := c.Param("seg")
mgr := transcode.Default()
job, ok := mgr.Scheduler.Get(jobID)
if !ok {
c.String(http.StatusNotFound, "job not found")
return
}
if job.CallbackToken != token {
c.String(http.StatusForbidden, "bad token")
return
}
// 【关键】job 之前因 idle 被 cancel 时,复用同一 jobID/token 重新入队启动转码。
// 这样浏览器持有的旧 master.m3u8 + 切片 URL 仍然有效,暂停再继续也不会 404。
if st := job.GetStatus(); st == transcode.JobCancelled || st == transcode.JobFinished || st == transcode.JobFailed {
if _, ok := mgr.Scheduler.Reactivate(jobID); ok {
fmt.Printf("[transcode] reactivate job %s on segment request (was %s)\n", jobID, st)
}
}
// seg-N.ts
name := strings.TrimSuffix(strings.TrimPrefix(segName, "seg-"), ".ts")
seq, err := strconv.Atoi(name)
if err != nil {
c.String(http.StatusBadRequest, "bad segment name")
return
}
cache := mgr.Cache
// 【智能调度】触发对应 chunk 的 ffmpeg 启动(如已运行则只更新 LastAccess)
// 这是用户拖动进度条到任意位置时能快速响应的关键
if mgr.Chunks != nil {
chunkIdx := mgr.Chunks.EnsureChunkRunningForSeg(jobID, profile, seq)
fmt.Printf("[tc-segment] req seq=%d chunk=%d job=%s\n", seq, chunkIdx, jobID)
}
info, err := cache.GetSegment(jobID, profile, seq)
if err != nil {
// 等待
waitStart := time.Now()
info, err = cache.WaitForSegment(jobID, profile, seq, 60*time.Second)
if err != nil {
fmt.Printf("[tc-segment] req seq=%d FAILED after %.1fs: %v\n", seq, time.Since(waitStart).Seconds(), err)
c.String(http.StatusNotFound, "segment not available: "+err.Error())
return
}
fmt.Printf("[tc-segment] req seq=%d ready after %.1fs\n", seq, time.Since(waitStart).Seconds())
}
job.Touch()
// 切片内容不可变,设置强缓存避免浏览器/HLS.js 反复请求同一切片
c.Header("Cache-Control", "public, max-age=86400, immutable")
// 【内存优化】流式发送切片文件,避免 gin c.File() 将整个切片读入内存
// 注意:不能使用 http.ServeFile,它会自动嗅探 Content-Type 并处理 Range 请求,
// 与 gin 已写入的响应头冲突,导致 HLS.js 收到异常数据触发 bufferAppendError。
f, err := os.Open(info.Path)
if err != nil {
c.String(http.StatusInternalServerError, "open segment: "+err.Error())
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
c.String(http.StatusInternalServerError, "stat segment: "+err.Error())
return
}
c.DataFromReader(http.StatusOK, fi.Size(), "video/mp2t", f, nil)
}
// ============================================================
// Worker 协议端
// ============================================================
// WorkerRegister POST /api/transcode/worker/register
func WorkerRegister(c *gin.Context) {
if !transcode.IsEnabled() {
common.ErrorStrResp(c, "transcode disabled", 403)
return
}
if !checkWorkerSecret(c) {
common.ErrorStrResp(c, "unauthorized", 401)
return
}
var req transcode.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
w := transcode.Default().Registry.Register(&req, false)
common.SuccessResp(c, transcode.RegisterResponse{
WorkerID: w.ID,
HeartbeatInterval: 10,
ClaimStrategy: "pull",
ProtocolVersion: transcode.ProtocolVersion,
})
}
// WorkerHeartbeat POST /api/transcode/worker/heartbeat
func WorkerHeartbeat(c *gin.Context) {
if !checkWorkerSecret(c) {
common.ErrorStrResp(c, "unauthorized", 401)
return
}
var req transcode.HeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
mgr := transcode.Default()
if _, ok := mgr.Registry.Heartbeat(&req); !ok {
common.SuccessResp(c, transcode.HeartbeatResponse{OK: false, Kick: true})
return
}
cancellations := mgr.Scheduler.PopCancellations(req.WorkerID)
common.SuccessResp(c, transcode.HeartbeatResponse{OK: true, Cancel: cancellations})
}
// WorkerClaim POST /api/transcode/worker/claim
func WorkerClaim(c *gin.Context) {
if !checkWorkerSecret(c) {
common.ErrorStrResp(c, "unauthorized", 401)
return
}
var req transcode.ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
wait := time.Duration(req.Wait) * time.Second
if wait > 30*time.Second {
wait = 30 * time.Second
}
jobs := transcode.Default().Scheduler.Claim(req.WorkerID, req.Slots, wait)
common.SuccessResp(c, transcode.ClaimResponse{Jobs: jobs})
}
// WorkerSegmentUpload PUT /api/transcode/worker/segment?job=&profile=&seq=&duration=&final=
func WorkerSegmentUpload(c *gin.Context) {
jobID := c.Query("job")
profile := c.Query("profile")
seq, _ := strconv.Atoi(c.Query("seq"))
dur, _ := strconv.ParseFloat(c.Query("duration"), 64)
isFinal, _ := strconv.ParseBool(c.Query("final"))
job, ok := transcode.Default().Scheduler.Get(jobID)
if !ok {
common.ErrorStrResp(c, "job not found", 404)
return
}
// 校验 callback_token (Bearer)
auth := c.GetHeader("Authorization")
expect := "Bearer " + job.CallbackToken
if auth != expect {
common.ErrorStrResp(c, "unauthorized", 401)
return
}
defer c.Request.Body.Close()
_, err := transcode.Default().Cache.PutSegment(jobID, profile, seq, dur, c.Request.Body, isFinal)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
if seq == 0 {
job.MarkReady()
}
c.Status(http.StatusNoContent)
}
// WorkerJobFinish POST /api/transcode/worker/job/finish
func WorkerJobFinish(c *gin.Context) {
var req transcode.FinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
job, ok := transcode.Default().Scheduler.Get(req.JobID)
if !ok {
common.ErrorStrResp(c, "job not found", 404)
return
}
auth := c.GetHeader("Authorization")
expect := "Bearer " + job.CallbackToken
if auth != expect {
common.ErrorStrResp(c, "unauthorized", 401)
return
}
if err := transcode.Default().Scheduler.Finish(&req); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}
// ListTranscodeWorkers GET /api/admin/transcode/workers (admin)
func ListTranscodeWorkers(c *gin.Context) {
common.SuccessResp(c, transcode.Default().Registry.List())
}
// ListTranscodeJobs GET /api/admin/transcode/jobs (admin)
func ListTranscodeJobs(c *gin.Context) {
common.SuccessResp(c, transcode.Default().Scheduler.ListJobs())
}
// CancelTranscodeJob POST /api/admin/transcode/cancel
func CancelTranscodeJob(c *gin.Context) {
var req struct {
JobID string `json:"job_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
transcode.Default().Scheduler.Cancel(req.JobID)
common.SuccessResp(c)
}
func checkWorkerSecret(c *gin.Context) bool {
auth := c.GetHeader("Authorization")
auth = strings.TrimPrefix(auth, "Bearer ")
return transcode.VerifyWorkerSecret(auth)
}
-55
View File
@@ -66,11 +66,6 @@ func Init(e *gin.Engine) {
g.HEAD("/sad/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, handles.SharingArchiveExtract)
g.HEAD("/sad/:sid/*path", middlewares.PathParse, middlewares.SharingIdParse, handles.SharingArchiveExtract)
// 转码播放:master/variant playlist 与切片下发;token 已包含在路径中作为鉴权
g.GET("/tc/:job/:token/master.m3u8", handles.TCMaster)
g.GET("/tc/:job/:token/:profile/playlist.m3u8", handles.TCPlaylist)
g.GET("/tc/:job/:token/:profile/:seg", handles.TCSegment)
api := g.Group("/api")
auth := api.Group("", middlewares.Auth(false))
webauthn := api.Group("/authn", middlewares.Authn)
@@ -107,14 +102,6 @@ func Init(e *gin.Engine) {
public.Any("/offline_download_tools", handles.OfflineDownloadTools)
public.Any("/archive_extensions", handles.ArchiveExtensions)
// 转码 Worker 协议端点(远程 Worker 节点对接,由共享密钥/token 鉴权,无需登录)
transcodeWorker := api.Group("/transcode/worker")
transcodeWorker.POST("/register", handles.WorkerRegister)
transcodeWorker.POST("/heartbeat", handles.WorkerHeartbeat)
transcodeWorker.POST("/claim", handles.WorkerClaim)
transcodeWorker.PUT("/segment", handles.WorkerSegmentUpload)
transcodeWorker.POST("/job/finish", handles.WorkerJobFinish)
_fs(auth.Group("/fs"))
fsAndShare(api.Group("/fs", middlewares.Auth(true)))
_task(auth.Group("/task", middlewares.AuthNotGuest))
@@ -199,36 +186,6 @@ func admin(g *gin.RouterGroup) {
scan.POST("/start", handles.StartManualScan)
scan.POST("/stop", handles.StopManualScan)
scan.GET("/progress", handles.GetManualScanProgress)
// 媒体库管理
mediaAdmin := g.Group("/media")
mediaAdmin.GET("/config/list", handles.ListMediaConfigs)
mediaAdmin.POST("/config/save", handles.SaveMediaConfig)
mediaAdmin.GET("/items", handles.ListMediaItemsAdmin)
mediaAdmin.POST("/items/update", handles.UpdateMediaItemAdmin)
mediaAdmin.POST("/items/delete", handles.DeleteMediaItemAdmin)
mediaAdmin.POST("/scan/start", handles.StartMediaScan)
mediaAdmin.GET("/scan/progress", handles.GetMediaScanProgress)
mediaAdmin.POST("/scrape/start", handles.StartMediaScrape)
mediaAdmin.GET("/scrape/stats", handles.GetMediaScrapeStats)
mediaAdmin.POST("/clear", handles.ClearMediaDB)
mediaAdmin.POST("/clear_scrape", handles.ClearMediaScrape)
mediaAdmin.POST("/delete_invalid", handles.DeleteInvalidMedia)
// 扫描路径管理
mediaAdmin.GET("/scan_paths", handles.ListMediaScanPaths)
mediaAdmin.POST("/scan_paths/create", handles.CreateMediaScanPath)
mediaAdmin.POST("/scan_paths/update", handles.UpdateMediaScanPath)
mediaAdmin.POST("/scan_paths/delete", handles.DeleteMediaScanPath)
mediaAdmin.POST("/scan_paths/clear", handles.ClearMediaScanPathDB)
// 导入 / 导出
mediaAdmin.GET("/export", handles.ExportMediaDB)
mediaAdmin.POST("/import", handles.ImportMediaDB)
// 转码管理
transcodeAdmin := g.Group("/transcode")
transcodeAdmin.GET("/workers", handles.ListTranscodeWorkers)
transcodeAdmin.GET("/jobs", handles.ListTranscodeJobs)
transcodeAdmin.POST("/cancel", handles.CancelTranscodeJob)
}
func fsAndShare(g *gin.RouterGroup) {
@@ -237,18 +194,6 @@ func fsAndShare(g *gin.RouterGroup) {
a := g.Group("/archive")
a.Any("/meta", handles.FsArchiveMetaSplit)
a.Any("/list", handles.FsArchiveListSplit)
// 媒体库公开API
mediaPublic := g.Group("/media")
mediaPublic.GET("/list", handles.PublicListMedia)
mediaPublic.GET("/item/:id", handles.PublicGetMedia)
mediaPublic.GET("/albums", handles.PublicListAlbums)
mediaPublic.GET("/album", handles.PublicGetAlbum)
mediaPublic.GET("/folders", handles.PublicListFolders)
mediaPublic.GET("/scan_paths", handles.PublicListScanPaths)
// 转码 - 用户态:申请播放
g.POST("/transcode/play", handles.TranscodePlay)
}
func _fs(g *gin.RouterGroup) {